diff --git a/CHANGELOG.md b/CHANGELOG.md index d28df1d..dcd2bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added config option `personalAccessToken` and environment variable `CONFLUENCE_PERSONAL_ACCESS_TOKEN` in order to support authentication using [personal access tokens](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html). (solves [#26](https://github.com/mihaeu/cosmere/issues/26)) + ## [0.14.1] - 2021-08-25 ### Fixed diff --git a/README.md b/README.md index 7bd5459..c4466f8 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,16 @@ Sync your markdown files to confluence. ## Features - - upload new versions only when necessary - - upload/delete local images as attachments - - convert PlantUML code fence to Confluence PlantUML macro - ``` +- upload new versions only when necessary +- upload/delete local images as attachments +- convert PlantUML code fence to Confluence PlantUML macro + ```` \```plantuml @startuml a --> b @enduml \``` - ``` + ```` ## Usage @@ -39,7 +39,7 @@ yarn add --dev cosmere ### Configuration -To get started generate configuration using +To get started generate configuration using ```bash cosmere generate-config [--config=] @@ -49,34 +49,43 @@ which produces: ```json { - "baseUrl": "YOUR_BASE_URL", - "user": "YOUR_USERNAME", - "pass": "YOUR_PASSWORD", - "cachePath": "build", - "prefix": "This document is automatically generated. Please don't edit it directly!", - "pages": [ - { - "pageId": "1234567890", - "file": "README.md", - "title": "Optional title in the confluence page, remove to use # h1 from markdown file instead" - } - ] + "baseUrl": "", + "user": "", + "pass": "", + "personalAccessToken": "", + "cachePath": "build", + "prefix": "This document is automatically generated. Please don't edit it directly!", + "pages": [ + { + "pageId": "1234567890", + "file": "README.md", + "title": "Optional title in the confluence page, remove to use # h1 from markdown file instead" + } + ] } ``` ### Continuous Integration -In most scenarios it is not recommended to store your credentials in the configuration file, because you will probably add it to your VCS. Instead it is recommended to provide the following environment variables in your build pipeline (GitLab CI, GitHub Actions, Jenkins, ...): +In most scenarios it is not recommended storing your credentials in the configuration file, because you will probably add it to your VCS. Instead, it is recommended to provide the following environment variables in your build pipeline (GitLab CI, GitHub Actions, Jenkins, ...): ```ini CONFLUENCE_USERNAME=YOUR_USERNAME CONFLUENCE_PASSWORD=YOUR_PASSWORD ``` +or + +```ini +CONFLUENCE_PERSONAL_ACCESS_TOKEN="" +``` + or add it in front of the command when executing locally (add a space in front of the command when using bash in order to not write the credentials to the bash history): ```bash CONFLUENCE_USER=YOUR_USERNAME CONFLUENCE_PASSWORD=YOUR_PASSWORD cosmere + # or + CONFLUENCE_PERSONAL_ACCESS_TOKEN="" cosmere ``` ### Run @@ -99,9 +108,9 @@ or create an alias: ```json { - "scripts": { - "pushdoc": "cosmere" - } + "scripts": { + "pushdoc": "cosmere" + } } ``` @@ -128,11 +137,14 @@ Please, feel free to create any issues and pull request that you need. ``` ## History + ### md2confluence + I had various scripts that stitched markdown files together and uploaded them. I forked [`md2confluence`](https://github.com/jormar/md2confluence) by [Jormar Arellano](https://github.com/jormar) and started playing around with that, but quickly noticed that many markdown files broke due to the conversion process (wiki -> storage instead of directly to storage). ### Cosmere -The project diverged from its original intent and so I decided to rename it. [Cosmere](https://coppermind.net/wiki/Cosmere) is the wonderful universe of various books written by [Brandon Sanderson](https://www.brandonsanderson.com/). If you are into fantasy I strongly recommend checking him out. + +The project diverged from its original intent and so I decided to rename it. [Cosmere](https://coppermind.net/wiki/Cosmere) is the wonderful universe of various books written by [Brandon Sanderson](https://www.brandonsanderson.com/). If you are into fantasy I strongly recommend checking him out. ## License diff --git a/bin/cosmere b/bin/cosmere index 8741078..8012b86 100755 --- a/bin/cosmere +++ b/bin/cosmere @@ -12,13 +12,13 @@ Options: `); if (options['generate-config']) { - require('../dist/src/GenerateCommand').default(options['--config']); + require('../dist/src/cli/GenerateCommand').default(options['--config']); } else { - require('../dist/src/MainCommand').default(options['--config'], options['--force']) + require('../dist/src/cli/MainCommand').default(options['--config'], options['--force']) .then() .catch(e => { require('signale').fatal(e); process.exit(1); } ); -} \ No newline at end of file +} diff --git a/package.json b/package.json index 7738a2d..adaaab4 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "homepage": "https://mihaeu.github.io/cosmere/", "devDependencies": { "@types/inquirer": "^6.5.0", + "@types/jest": "^27.4.0", "@types/marked": "^0.7.1", "@types/node": "^12.12.7", "@types/signale": "^1.2.1", diff --git a/src/ConfigLoader.ts b/src/ConfigLoader.ts index 2292392..0e7a5aa 100644 --- a/src/ConfigLoader.ts +++ b/src/ConfigLoader.ts @@ -3,40 +3,74 @@ import * as fs from "fs"; import { Config } from "./types/Config"; import * as inquirer from "inquirer"; import signale from "signale"; +import { FileConfig } from "./types/FileConfig"; + +type AuthOptions = { + user?: string; + pass?: string; + personalAccessToken?: string; +}; export class ConfigLoader { static async load(configPath: string | null): Promise { - return await ConfigLoader.promptUserAndPassIfNotSet( - ConfigLoader.overwriteAuthFromConfigWithEnvIfPresent(ConfigLoader.readConfigFromFile(configPath)), + const fileConfig = ConfigLoader.readConfigFromFile(configPath); + const authOptions = await ConfigLoader.promptUserAndPassIfNotSet( + ConfigLoader.useAuthOptionsFromEnvIfPresent(ConfigLoader.authOptionsFromFileConfig(fileConfig)), ); + return ConfigLoader.createConfig(fileConfig, ConfigLoader.createAuthorizationToken(authOptions)); } - private static readConfigFromFile(configPath: string | null): Config { + private static readConfigFromFile(configPath: string | null, authorizationToken?: string): FileConfig { configPath = path.resolve(configPath || path.join("cosmere.json")); if (!fs.existsSync(configPath!)) { signale.fatal(`File "${configPath}" not found!`); process.exit(1); } - let config = JSON.parse(fs.readFileSync(configPath!, "utf8")) as Config; + let config = JSON.parse(fs.readFileSync(configPath!, "utf8")) as Omit; for (const i in config.pages) { config.pages[i].file = path.isAbsolute(config.pages[i].file) ? config.pages[i].file : path.resolve(path.dirname(configPath) + "/" + config.pages[i].file); } - config.configPath = configPath; - return config; + + return { + ...config, + configPath, + }; + } + + private static createAuthorizationToken(authOptions: AuthOptions): string { + if (authOptions.personalAccessToken) { + return `Bearer ${authOptions.personalAccessToken}`; + } + + if (authOptions.user && authOptions.user.length > 0 && authOptions.pass && authOptions.pass.length > 0) { + const encodedBasicToken = Buffer.from(`${authOptions.user}:${authOptions.pass}`).toString("base64"); + return `Basic ${encodedBasicToken}`; + } + + signale.fatal( + "Missing configuration! You must either provide a combination of your Confluence username and password or a personal access token.", + ); + process.exit(2); } - private static overwriteAuthFromConfigWithEnvIfPresent(config: Config): Config { - config.user = process.env.CONFLUENCE_USERNAME || config.user; - config.pass = process.env.CONFLUENCE_PASSWORD || config.pass; - return config; + private static useAuthOptionsFromEnvIfPresent(authOptions: AuthOptions): AuthOptions { + return { + user: process.env.CONFLUENCE_USERNAME || authOptions.user, + pass: process.env.CONFLUENCE_PASSWORD || authOptions.pass, + personalAccessToken: process.env.CONFLUENCE_PERSONAL_ACCESS_TOKEN || authOptions.personalAccessToken, + }; } - private static async promptUserAndPassIfNotSet(config: Config): Promise { + private static async promptUserAndPassIfNotSet(authOptions: AuthOptions): Promise { + if (authOptions.personalAccessToken && authOptions.personalAccessToken.length > 0) { + return authOptions; + } + const prompts = []; - if (!config.user) { + if (!authOptions.user) { prompts.push({ type: "input", name: "user", @@ -44,7 +78,7 @@ export class ConfigLoader { }); } - if (!config.pass) { + if (!authOptions.pass) { prompts.push({ type: "password", name: "pass", @@ -53,9 +87,29 @@ export class ConfigLoader { } const answers = await inquirer.prompt(prompts); - config.user = config.user || (answers.user as string); - config.pass = config.pass || (answers.pass as string); + return { + user: authOptions.user || (answers.user as string), + pass: authOptions.pass || (answers.pass as string), + personalAccessToken: authOptions.personalAccessToken, + }; + } + + private static authOptionsFromFileConfig(fileConfig: FileConfig): AuthOptions { + return { + user: fileConfig.user, + pass: fileConfig.pass, + personalAccessToken: fileConfig.personalAccessToken, + }; + } - return config; + private static createConfig(fileConfig: FileConfig, authorizationToken: string): Config { + return { + baseUrl: fileConfig.baseUrl, + cachePath: fileConfig.cachePath, + prefix: fileConfig.prefix, + pages: fileConfig.pages, + configPath: fileConfig.configPath, + authorizationToken: authorizationToken, + }; } } diff --git a/src/ConfluenceRenderer.ts b/src/ConfluenceRenderer.ts index 37ec17b..5945aa8 100644 --- a/src/ConfluenceRenderer.ts +++ b/src/ConfluenceRenderer.ts @@ -37,21 +37,25 @@ export default class ConfluenceRenderer extends Renderer { } private static renderDetailsBlock(html: string): string { - const summary = html.match(/([\s\S]*)<\/summary>/)?.[1] ?? 'Click here to expand ...' + const summary = html.match(/([\s\S]*)<\/summary>/)?.[1] ?? "Click here to expand ..."; const contentWithoutSummaryTags = html - .replace(/([\s\S]*)<\/summary>/, '') - .replace(/<\/?details>/g, ''); - const content = - marked( - contentWithoutSummaryTags, { - renderer: new ConfluenceRenderer(), - xhtml: true, - }); + .replace(/([\s\S]*)<\/summary>/, "") + .replace(/<\/?details>/g, ""); + const content = marked(contentWithoutSummaryTags, { + renderer: new ConfluenceRenderer(), + xhtml: true, + }); - return '' - + '' + summary + '' - + '' + content + '' - + ''; + return ( + '' + + '' + + summary + + "" + + "" + + content + + "" + + "" + ); } html(html: string): string { diff --git a/src/api/ConfluenceAPI.ts b/src/api/ConfluenceAPI.ts index 46dbc8c..500c592 100644 --- a/src/api/ConfluenceAPI.ts +++ b/src/api/ConfluenceAPI.ts @@ -1,28 +1,26 @@ -import { AuthHeaders } from "../types/AuthHeaders"; import axios from "axios"; import * as fs from "fs"; import signale from "signale"; export class ConfluenceAPI { - private readonly authHeaders: AuthHeaders; private readonly baseUrl: string; + private readonly authHeader: { + Authorization: string; + }; - constructor(baseUrl: string, username: string, password: string) { + constructor(baseUrl: string, authorizationToken: string) { this.baseUrl = baseUrl; - this.authHeaders = { - auth: { - username: username, - password: password, - }, + this.authHeader = { + Authorization: authorizationToken, }; } async updateConfluencePage(pageId: string, newPage: any) { const config = { headers: { + ...this.authHeader, "Content-Type": "application/json", }, - ...this.authHeaders, }; try { await axios.put(`${this.baseUrl}/content/${pageId}`, newPage, config); @@ -33,11 +31,15 @@ export class ConfluenceAPI { } async deleteAttachments(pageId: string) { - const attachments = await axios.get(`${this.baseUrl}/content/${pageId}/child/attachment`, this.authHeaders); + const attachments = await axios.get(`${this.baseUrl}/content/${pageId}/child/attachment`, { + headers: this.authHeader, + }); for (const attachment of attachments.data.results) { try { signale.await(`Deleting attachment "${attachment.title}" ...`); - await axios.delete(`${this.baseUrl}/content/${attachment.id}`, this.authHeaders); + await axios.delete(`${this.baseUrl}/content/${attachment.id}`, { + headers: this.authHeader, + }); } catch (e) { signale.error(`Deleting attachment "${attachment.title}" failed ...`); } @@ -51,11 +53,11 @@ export class ConfluenceAPI { method: "post", headers: { "X-Atlassian-Token": "nocheck", + ...this.authHeader, }, data: { file: fs.createReadStream(filename), }, - ...this.authHeaders, }); } catch (e) { signale.error(`Uploading attachment "${filename}" failed ...`); @@ -63,6 +65,8 @@ export class ConfluenceAPI { } async currentPage(pageId: string) { - return axios.get(`${this.baseUrl}/content/${pageId}?expand=body.storage,version`, this.authHeaders); + return axios.get(`${this.baseUrl}/content/${pageId}?expand=body.storage,version`, { + headers: this.authHeader, + }); } } diff --git a/src/cli/GenerateCommand.ts b/src/cli/GenerateCommand.ts index 4c1fede..07d19f0 100644 --- a/src/cli/GenerateCommand.ts +++ b/src/cli/GenerateCommand.ts @@ -5,9 +5,10 @@ export default function(configPath: string | null) { fs.writeFileSync( configPath || path.join("cosmere.json")!, `{ - "baseUrl": "YOUR_BASE_URL", - "user": "YOUR_USERNAME", - "pass": "YOUR_PASSWORD", + "baseUrl": "", + "user": "", + "pass": "", + "personalAccessToken": "", "cachePath": "build", "prefix": "This document is automatically generated. Please don't edit it directly!", "pages": [ diff --git a/src/cli/MainCommand.ts b/src/cli/MainCommand.ts index ed1a5d3..1f9939b 100644 --- a/src/cli/MainCommand.ts +++ b/src/cli/MainCommand.ts @@ -5,7 +5,7 @@ import { updatePage } from "../UpdatePage"; export default async function(configPath: string | null, force: boolean = false) { const config: Config = await ConfigLoader.load(configPath); - const confluenceAPI = new ConfluenceAPI(config.baseUrl, config.user!, config.pass!); + const confluenceAPI = new ConfluenceAPI(config.baseUrl, config.authorizationToken); for (const pageData of config.pages) { await updatePage(confluenceAPI, pageData, config, force); diff --git a/src/types/AuthHeaders.ts b/src/types/AuthHeaders.ts deleted file mode 100644 index e331f32..0000000 --- a/src/types/AuthHeaders.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type AuthHeaders = { - auth: { - username: string; - password: string; - }; -}; diff --git a/src/types/Config.ts b/src/types/Config.ts index e95d6c9..8fb2b5b 100644 --- a/src/types/Config.ts +++ b/src/types/Config.ts @@ -3,9 +3,8 @@ import { Page } from "./Page"; export type Config = { baseUrl: string; cachePath: string; - user?: string; - pass?: string; prefix: string; pages: Page[]; - configPath: string | null; + configPath: string; + authorizationToken: string; }; diff --git a/src/types/FileConfig.ts b/src/types/FileConfig.ts new file mode 100644 index 0000000..5d59a89 --- /dev/null +++ b/src/types/FileConfig.ts @@ -0,0 +1,12 @@ +import { Page } from "./Page"; + +export type FileConfig = { + baseUrl: string; + cachePath: string; + prefix: string; + pages: Page[]; + configPath: string; + user?: string; + pass?: string; + personalAccessToken?: string; +}; diff --git a/tests/ConfigLoader.test.ts b/tests/ConfigLoader.test.ts index 531d55f..0f5781d 100644 --- a/tests/ConfigLoader.test.ts +++ b/tests/ConfigLoader.test.ts @@ -1,7 +1,33 @@ import { ConfigLoader } from "../src/ConfigLoader"; describe("ConfigLoader", () => { - it.skip("fails", () => { - expect(new ConfigLoader()).toBeFalsy(); - }); -}); \ No newline at end of file + it("should create bearer token from personal access token", async () => { + expect( + await ConfigLoader.load(__dirname + "/resources/test-config-with-personal-access-token-auth.json"), + ).toEqual({ + authorizationToken: "Bearer unbearable", + configPath: __dirname + "/resources/test-config-with-personal-access-token-auth.json", + ...irrelevantConfigFields, + }); + }); + + it("should create base64 basic token from username and password", async () => { + expect(await ConfigLoader.load(__dirname + "/resources/test-config-with-user-pass-auth.json")).toEqual({ + authorizationToken: "Basic dXNlcjpwYXNz", + configPath: __dirname + "/resources/test-config-with-user-pass-auth.json", + ...irrelevantConfigFields, + }); + }); + + const irrelevantConfigFields = { + baseUrl: "https://confluence.custom.host/rest/api", + cachePath: "cache/", + pages: [ + { + file: __dirname + "/resources/README.md", + pageId: "123456789", + }, + ], + prefix: "This document is automatically generated. Please don't edit it directly!", + }; +}); diff --git a/tests/UpdatePage.test.ts b/tests/UpdatePage.test.ts index 22993fd..8276570 100644 --- a/tests/UpdatePage.test.ts +++ b/tests/UpdatePage.test.ts @@ -4,18 +4,19 @@ import { Page } from "../src/types/Page"; import { Config } from "../src/types/Config"; describe("UpdatePage", () => { - it.skip("fails", () => { - const pageData: Page = { - pageId: "123456789", - file: "/dev/null" - }; - const config: Config = { - baseUrl: "string", - cachePath: "string", - prefix: "string", - pages: [], - configPath: null, - }; - expect(updatePage(new ConfluenceAPI("", "", ""), pageData, config, false)).toBeFalsy(); - }); -}); \ No newline at end of file + it.skip("fails", () => { + const pageData: Page = { + pageId: "123456789", + file: "/dev/null", + }; + const config: Config = { + baseUrl: "string", + cachePath: "string", + prefix: "string", + pages: [], + configPath: "...", + authorizationToken: "Bearer unbearable", + }; + expect(updatePage(new ConfluenceAPI("", "Bearer unbearable"), pageData, config, false)).toBeFalsy(); + }); +}); diff --git a/tests/api/ConfluenceAPI.test.ts b/tests/api/ConfluenceAPI.test.ts index 6f9dfd7..c23b8fc 100644 --- a/tests/api/ConfluenceAPI.test.ts +++ b/tests/api/ConfluenceAPI.test.ts @@ -2,23 +2,19 @@ import { ConfluenceAPI } from "../../src/api/ConfluenceAPI"; import axios from "axios"; import { mocked } from "ts-jest/utils"; -jest.mock('axios'); +jest.mock("axios"); const axiosMock = mocked(axios, true); describe("ConfluenceAPI", () => { - it("fetches current version of confluence page", async () => { - axiosMock.get.mockResolvedValue({ data: "Test"}); + it("fetches current version of confluence page", async () => { + axiosMock.get.mockResolvedValue({ data: "Test" }); - const confluenceAPI = new ConfluenceAPI("", "", ""); - await confluenceAPI.currentPage("2"); - expect(axiosMock.get).toHaveBeenCalledWith( - "/content/2?expand=body.storage,version", - { - auth: { - password: "", - username: "" - } - } - ); - }); -}); \ No newline at end of file + const confluenceAPI = new ConfluenceAPI("", "Bearer unbearable"); + await confluenceAPI.currentPage("2"); + expect(axiosMock.get).toHaveBeenCalledWith("/content/2?expand=body.storage,version", { + headers: { + Authorization: "Bearer unbearable", + }, + }); + }); +}); diff --git a/tests/cli/GenerateCommand.test.ts b/tests/cli/GenerateCommand.test.ts index c6c8016..37155a8 100644 --- a/tests/cli/GenerateCommand.test.ts +++ b/tests/cli/GenerateCommand.test.ts @@ -3,13 +3,14 @@ import * as os from "os"; import * as fs from "fs"; describe("GenerateCommand", () => { - it("fails", () => { - const path = os.tmpdir() + '/cosmere.json'; - GenerateCommand(path); - expect(fs.readFileSync(path, "utf8")).toBe(`{ - "baseUrl": "YOUR_BASE_URL", - "user": "YOUR_USERNAME", - "pass": "YOUR_PASSWORD", + it("fails", () => { + const path = os.tmpdir() + "/cosmere.json"; + GenerateCommand(path); + expect(fs.readFileSync(path, "utf8")).toBe(`{ + "baseUrl": "", + "user": "", + "pass": "", + "personalAccessToken": "", "cachePath": "build", "prefix": "This document is automatically generated. Please don't edit it directly!", "pages": [ @@ -21,5 +22,5 @@ describe("GenerateCommand", () => { ] } `); - }); -}); \ No newline at end of file + }); +}); diff --git a/tests/resources/test-config-with-personal-access-token-auth.json b/tests/resources/test-config-with-personal-access-token-auth.json new file mode 100644 index 0000000..c5f7ba9 --- /dev/null +++ b/tests/resources/test-config-with-personal-access-token-auth.json @@ -0,0 +1,12 @@ +{ + "baseUrl": "https://confluence.custom.host/rest/api", + "personalAccessToken": "unbearable", + "prefix": "This document is automatically generated. Please don't edit it directly!", + "cachePath": "cache/", + "pages": [ + { + "pageId": "123456789", + "file": "README.md" + } + ] +} diff --git a/tests/resources/test-config-with-user-pass-auth.json b/tests/resources/test-config-with-user-pass-auth.json new file mode 100644 index 0000000..ca8e140 --- /dev/null +++ b/tests/resources/test-config-with-user-pass-auth.json @@ -0,0 +1,13 @@ +{ + "baseUrl": "https://confluence.custom.host/rest/api", + "user": "user", + "pass": "pass", + "prefix": "This document is automatically generated. Please don't edit it directly!", + "cachePath": "cache/", + "pages": [ + { + "pageId": "123456789", + "file": "README.md" + } + ] +} diff --git a/yarn.lock b/yarn.lock index b2ec778..97693f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -648,6 +648,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@^27.4.0": + version "27.4.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.4.0.tgz#037ab8b872067cae842a320841693080f9cb84ed" + integrity sha512-gHl8XuC1RZ8H2j5sHv/JqsaxXkDDM9iDOgu0Wp8sjs4u/snb2PVehyWXJPr+ORA0RPpgw231mnutWI1+0hgjIQ== + dependencies: + jest-diff "^27.0.0" + pretty-format "^27.0.0" + "@types/keyv@*", "@types/keyv@^3.1.1": version "3.1.3" resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.3.tgz#1c9aae32872ec1f20dcdaee89a9f3ba88f465e41" @@ -829,6 +837,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + any-observable@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" @@ -1588,6 +1601,11 @@ diff-sequences@^26.6.2: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== +diff-sequences@^27.4.0: + version "27.4.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.4.0.tgz#d783920ad8d06ec718a060d00196dfef25b132a5" + integrity sha512-YqiQzkrsmHMH5uuh8OdQFU9/ZpADnwzml8z0O5HvRNda+5UZsaX/xN+AAxfR2hWq1Y7HZnAzO9J5lJXOuDz2Ww== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -2788,6 +2806,16 @@ jest-diff@^26.6.2: jest-get-type "^26.3.0" pretty-format "^26.6.2" +jest-diff@^27.0.0: + version "27.4.6" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.4.6.tgz#93815774d2012a2cbb6cf23f84d48c7a2618f98d" + integrity sha512-zjaB0sh0Lb13VyPsd92V7HkqF6yKRH9vm33rwBt7rPYrpQvS1nCvlIy2pICbKta+ZjWngYLNn4cCK4nyZkjS/w== + dependencies: + chalk "^4.0.0" + diff-sequences "^27.4.0" + jest-get-type "^27.4.0" + pretty-format "^27.4.6" + jest-docblock@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5" @@ -2836,6 +2864,11 @@ jest-get-type@^26.3.0: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== +jest-get-type@^27.4.0: + version "27.4.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.4.0.tgz#7503d2663fffa431638337b3998d39c5e928e9b5" + integrity sha512-tk9o+ld5TWq41DkK14L4wox4s2D9MtTpKaAVzXfr5CUKm5ZK2ExcaFE0qls2W71zE/6R2TxxrK9w2r6svAFDBQ== + jest-haste-map@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" @@ -4145,6 +4178,15 @@ pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" +pretty-format@^27.0.0, pretty-format@^27.4.6: + version "27.4.6" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.4.6.tgz#1b784d2f53c68db31797b2348fa39b49e31846b7" + integrity sha512-NblstegA1y/RJW2VyML+3LlpFjzx62cUrtBIKIWDXEDkjNeleA7Od7nrzcs/VLQvAeV4CgSYhrN39DRN88Qi/g== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + prompts@^2.0.1: version "2.4.1" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.1.tgz#befd3b1195ba052f9fd2fde8a486c4e82ee77f61"