Merge pull request #8046 from Budibase/feature/cli-hosting-improvements

CLI hosting improvements
This commit is contained in:
Michael Drury 2022-09-30 12:11:42 +01:00 committed by GitHub
commit c02e67f6c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 559 additions and 234 deletions

View File

@ -45,7 +45,9 @@
"pouchdb": "7.3.0",
"pouchdb-replication-stream": "1.2.9",
"randomstring": "1.1.5",
"tar": "6.1.11"
"tar": "6.1.11",
"yaml": "^2.1.1",
"find-free-port": "^2.0.0"
},
"devDependencies": {
"copyfiles": "^2.4.1",

View File

@ -0,0 +1,17 @@
const { success } = require("../utils")
const { updateDockerComposeService } = require("./utils")
const randomString = require("randomstring")
exports.generateUser = async () => {
const email = "admin@admin.com"
const password = randomString.generate({ length: 6 })
updateDockerComposeService(service => {
service.environment["BB_ADMIN_USER_EMAIL"] = email
service.environment["BB_ADMIN_USER_PASSWORD"] = password
})
console.log(
success(
`User admin credentials configured, access with email: ${email} - password: ${password}`
)
)
}

View File

@ -1,164 +1,18 @@
const Command = require("../structures/Command")
const { CommandWords, InitTypes, AnalyticsEvents } = require("../constants")
const { lookpath } = require("lookpath")
const {
downloadFile,
logErrorToFile,
success,
info,
parseEnv,
} = require("../utils")
const { confirmation } = require("../questions")
const fs = require("fs")
const compose = require("docker-compose")
const makeEnv = require("./makeEnv")
const axios = require("axios")
const { captureEvent } = require("../events")
const BUDIBASE_SERVICES = ["app-service", "worker-service", "proxy-service"]
const ERROR_FILE = "docker-error.log"
const FILE_URLS = [
"https://raw.githubusercontent.com/Budibase/budibase/master/hosting/docker-compose.yaml",
]
const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data"
async function downloadFiles() {
const promises = []
for (let url of FILE_URLS) {
const fileName = url.split("/").slice(-1)[0]
promises.push(downloadFile(url, `./${fileName}`))
}
await Promise.all(promises)
}
async function checkDockerConfigured() {
const error =
"docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose"
const docker = await lookpath("docker")
const compose = await lookpath("docker-compose")
if (!docker || !compose) {
throw error
}
}
function checkInitComplete() {
if (!fs.existsSync(makeEnv.filePath)) {
throw "Please run the hosting --init command before any other hosting command."
}
}
async function handleError(func) {
try {
await func()
} catch (err) {
if (err && err.err) {
logErrorToFile(ERROR_FILE, err.err)
}
throw `Failed to start - logs written to file: ${ERROR_FILE}`
}
}
async function init(type) {
const isQuick = type === InitTypes.QUICK || type === InitTypes.DIGITAL_OCEAN
await checkDockerConfigured()
if (!isQuick) {
const shouldContinue = await confirmation(
"This will create multiple files in current directory, should continue?"
)
if (!shouldContinue) {
console.log("Stopping.")
return
}
}
captureEvent(AnalyticsEvents.SelfHostInit, {
type,
})
await downloadFiles()
const config = isQuick ? makeEnv.QUICK_CONFIG : {}
if (type === InitTypes.DIGITAL_OCEAN) {
try {
const output = await axios.get(DO_USER_DATA_URL)
const response = parseEnv(output.data)
for (let [key, value] of Object.entries(makeEnv.ConfigMap)) {
if (response[key]) {
config[value] = response[key]
}
}
} catch (err) {
// don't need to handle error, just don't do anything
}
}
await makeEnv.make(config)
}
async function start() {
await checkDockerConfigured()
checkInitComplete()
console.log(
info(
"Starting services, this may take a moment - first time this may take a few minutes to download images."
)
)
const port = makeEnv.get("MAIN_PORT")
await handleError(async () => {
// need to log as it makes it more clear
await compose.upAll({ cwd: "./", log: true })
})
console.log(
success(
`Services started, please go to http://localhost:${port} for next steps.`
)
)
}
async function status() {
await checkDockerConfigured()
checkInitComplete()
console.log(info("Budibase status"))
await handleError(async () => {
const response = await compose.ps()
console.log(response.out)
})
}
async function stop() {
await checkDockerConfigured()
checkInitComplete()
console.log(info("Stopping services, this may take a moment."))
await handleError(async () => {
await compose.stop()
})
console.log(success("Services have been stopped successfully."))
}
async function update() {
await checkDockerConfigured()
checkInitComplete()
if (await confirmation("Do you wish to update you docker-compose.yaml?")) {
await downloadFiles()
}
await handleError(async () => {
const status = await compose.ps()
const parts = status.out.split("\n")
const isUp = parts[2] && parts[2].indexOf("Up") !== -1
if (isUp) {
console.log(info("Stopping services, this may take a moment."))
await compose.stop()
}
console.log(info("Beginning update, this may take a few minutes."))
await compose.pullMany(BUDIBASE_SERVICES, { log: true })
if (isUp) {
console.log(success("Update complete, restarting services..."))
await start()
}
})
}
const { CommandWords } = require("../constants")
const { init } = require("./init")
const { start } = require("./start")
const { stop } = require("./stop")
const { status } = require("./status")
const { update } = require("./update")
const { generateUser } = require("./genUser")
const { watchPlugins } = require("./watch")
const command = new Command(`${CommandWords.HOSTING}`)
.addHelp("Controls self hosting on the Budibase platform.")
.addSubOption(
"--init [type]",
"Configure a self hosted platform in current directory, type can be unspecified or 'quick'.",
"Configure a self hosted platform in current directory, type can be unspecified, 'quick' or 'single'.",
init
)
.addSubOption(
@ -181,5 +35,16 @@ const command = new Command(`${CommandWords.HOSTING}`)
"Update the Budibase images to the latest version.",
update
)
.addSubOption(
"--watch-plugin-dir [directory]",
"Add plugin directory watching to a Budibase install.",
watchPlugins
)
.addSubOption(
"--gen-user",
"Create an admin user automatically as part of first start.",
generateUser
)
.addSubOption("--single", "Specify this with init to use the single image.")
exports.command = command

View File

@ -0,0 +1,73 @@
const { InitTypes, AnalyticsEvents } = require("../constants")
const { confirmation } = require("../questions")
const { captureEvent } = require("../events")
const makeFiles = require("./makeFiles")
const axios = require("axios")
const { parseEnv } = require("../utils")
const { checkDockerConfigured, downloadFiles } = require("./utils")
const { watchPlugins } = require("./watch")
const { generateUser } = require("./genUser")
const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data"
async function getInitConfig(type, isQuick, port) {
const config = isQuick ? makeFiles.QUICK_CONFIG : {}
if (type === InitTypes.DIGITAL_OCEAN) {
try {
const output = await axios.get(DO_USER_DATA_URL)
const response = parseEnv(output.data)
for (let [key, value] of Object.entries(makeFiles.ConfigMap)) {
if (response[key]) {
config[value] = response[key]
}
}
} catch (err) {
// don't need to handle error, just don't do anything
}
}
// override port
if (port) {
config[makeFiles.ConfigMap.MAIN_PORT] = port
}
return config
}
exports.init = async opts => {
let type, isSingle, watchDir, genUser, port
if (typeof opts === "string") {
type = opts
} else {
type = opts["init"]
isSingle = opts["single"]
watchDir = opts["watchPluginDir"]
genUser = opts["genUser"]
port = opts["port"]
}
const isQuick = type === InitTypes.QUICK || type === InitTypes.DIGITAL_OCEAN
await checkDockerConfigured()
if (!isQuick) {
const shouldContinue = await confirmation(
"This will create multiple files in current directory, should continue?"
)
if (!shouldContinue) {
console.log("Stopping.")
return
}
}
captureEvent(AnalyticsEvents.SelfHostInit, {
type,
})
const config = await getInitConfig(type, isQuick, port)
if (!isSingle) {
await downloadFiles()
await makeFiles.makeEnv(config)
} else {
await makeFiles.makeSingleCompose(config)
}
if (watchDir) {
await watchPlugins(watchDir)
}
if (genUser) {
await generateUser()
}
}

View File

@ -1,66 +0,0 @@
const { number } = require("../questions")
const { success } = require("../utils")
const fs = require("fs")
const path = require("path")
const randomString = require("randomstring")
const FILE_PATH = path.resolve("./.env")
function getContents(port) {
return `
# Use the main port in the builder for your self hosting URL, e.g. localhost:10000
MAIN_PORT=${port}
# This section contains all secrets pertaining to the system
JWT_SECRET=${randomString.generate()}
MINIO_ACCESS_KEY=${randomString.generate()}
MINIO_SECRET_KEY=${randomString.generate()}
COUCH_DB_PASSWORD=${randomString.generate()}
COUCH_DB_USER=${randomString.generate()}
REDIS_PASSWORD=${randomString.generate()}
INTERNAL_API_KEY=${randomString.generate()}
# This section contains variables that do not need to be altered under normal circumstances
APP_PORT=4002
WORKER_PORT=4003
MINIO_PORT=4004
COUCH_DB_PORT=4005
REDIS_PORT=6379
WATCHTOWER_PORT=6161
BUDIBASE_ENVIRONMENT=PRODUCTION`
}
module.exports.filePath = FILE_PATH
module.exports.ConfigMap = {
MAIN_PORT: "port",
}
module.exports.QUICK_CONFIG = {
key: "budibase",
port: 10000,
}
module.exports.make = async (inputs = {}) => {
const hostingPort =
inputs.port ||
(await number(
"Please enter the port on which you want your installation to run: ",
10000
))
const fileContents = getContents(hostingPort)
fs.writeFileSync(FILE_PATH, fileContents)
console.log(
success(
"Configuration has been written successfully - please check .env file for more details."
)
)
}
module.exports.get = property => {
const props = fs.readFileSync(FILE_PATH, "utf8").split(property)
if (props[0].charAt(0) === "=") {
property = props[0]
} else {
property = props[1]
}
return property.split("=")[1].split("\n")[0]
}

View File

@ -0,0 +1,135 @@
const { number } = require("../questions")
const { success, stringifyToDotEnv } = require("../utils")
const fs = require("fs")
const path = require("path")
const randomString = require("randomstring")
const yaml = require("yaml")
const { getAppService } = require("./utils")
const SINGLE_IMAGE = "budibase/budibase:latest"
const VOL_NAME = "budibase_data"
const COMPOSE_PATH = path.resolve("./docker-compose.yaml")
const ENV_PATH = path.resolve("./.env")
function getSecrets(opts = { single: false }) {
const secrets = [
"JWT_SECRET",
"MINIO_ACCESS_KEY",
"MINIO_SECRET_KEY",
"REDIS_PASSWORD",
"INTERNAL_API_KEY",
]
const obj = {}
secrets.forEach(secret => (obj[secret] = randomString.generate()))
// setup couch creds separately
if (opts && opts.single) {
obj["COUCHDB_USER"] = "admin"
obj["COUCHDB_PASSWORD"] = randomString.generate()
} else {
obj["COUCH_DB_USER"] = "admin"
obj["COUCH_DB_PASSWORD"] = randomString.generate()
}
return obj
}
function getSingleCompose(port) {
const singleComposeObj = {
version: "3",
services: {
budibase: {
restart: "unless-stopped",
image: SINGLE_IMAGE,
ports: [`${port}:80`],
environment: getSecrets({ single: true }),
volumes: [`${VOL_NAME}:/data`],
},
},
volumes: {
[VOL_NAME]: {
driver: "local",
},
},
}
return yaml.stringify(singleComposeObj)
}
function getEnv(port) {
const partOne = stringifyToDotEnv({
MAIN_PORT: port,
})
const partTwo = stringifyToDotEnv(getSecrets())
const partThree = stringifyToDotEnv({
APP_PORT: 4002,
WORKER_PORT: 4003,
MINIO_PORT: 4004,
COUCH_DB_PORT: 4005,
REDIS_PORT: 6379,
WATCHTOWER_PORT: 6161,
BUDIBASE_ENVIRONMENT: "PRODUCTION",
})
return [
"# Use the main port in the builder for your self hosting URL, e.g. localhost:10000",
partOne,
"# This section contains all secrets pertaining to the system",
partTwo,
"# This section contains variables that do not need to be altered under normal circumstances",
partThree,
].join("\n")
}
exports.ENV_PATH = ENV_PATH
exports.COMPOSE_PATH = COMPOSE_PATH
module.exports.ConfigMap = {
MAIN_PORT: "port",
}
module.exports.QUICK_CONFIG = {
key: "budibase",
port: 10000,
}
async function make(path, contentsFn, inputs = {}) {
const port =
inputs.port ||
(await number(
"Please enter the port on which you want your installation to run: ",
10000
))
const fileContents = contentsFn(port)
fs.writeFileSync(path, fileContents)
console.log(
success(
`Configuration has been written successfully - please check ${path} for more details.`
)
)
}
module.exports.makeEnv = async (inputs = {}) => {
return make(ENV_PATH, getEnv, inputs)
}
module.exports.makeSingleCompose = async (inputs = {}) => {
return make(COMPOSE_PATH, getSingleCompose, inputs)
}
module.exports.getEnvProperty = property => {
const props = fs.readFileSync(ENV_PATH, "utf8").split(property)
if (props[0].charAt(0) === "=") {
property = props[0]
} else {
property = props[1]
}
return property.split("=")[1].split("\n")[0]
}
module.exports.getComposeProperty = property => {
const { service } = getAppService(COMPOSE_PATH)
if (property === "port" && Array.isArray(service.ports)) {
const port = service.ports[0]
return port.split(":")[0]
} else if (service.environment) {
return service.environment[property]
}
return null
}

View File

@ -0,0 +1,34 @@
const {
checkDockerConfigured,
checkInitComplete,
handleError,
} = require("./utils")
const { info, success } = require("../utils")
const makeFiles = require("./makeFiles")
const compose = require("docker-compose")
const fs = require("fs")
exports.start = async () => {
await checkDockerConfigured()
checkInitComplete()
console.log(
info(
"Starting services, this may take a moment - first time this may take a few minutes to download images."
)
)
let port
if (fs.existsSync(makeFiles.ENV_PATH)) {
port = makeFiles.getEnvProperty("MAIN_PORT")
} else {
port = makeFiles.getComposeProperty("port")
}
await handleError(async () => {
// need to log as it makes it more clear
await compose.upAll({ cwd: "./", log: true })
})
console.log(
success(
`Services started, please go to http://localhost:${port} for next steps.`
)
)
}

View File

@ -0,0 +1,17 @@
const {
checkDockerConfigured,
checkInitComplete,
handleError,
} = require("./utils")
const { info } = require("../utils")
const compose = require("docker-compose")
exports.status = async () => {
await checkDockerConfigured()
checkInitComplete()
console.log(info("Budibase status"))
await handleError(async () => {
const response = await compose.ps()
console.log(response.out)
})
}

View File

@ -0,0 +1,17 @@
const {
checkDockerConfigured,
checkInitComplete,
handleError,
} = require("./utils")
const { info, success } = require("../utils")
const compose = require("docker-compose")
exports.stop = async () => {
await checkDockerConfigured()
checkInitComplete()
console.log(info("Stopping services, this may take a moment."))
await handleError(async () => {
await compose.stop()
})
console.log(success("Services have been stopped successfully."))
}

View File

@ -0,0 +1,49 @@
const {
checkDockerConfigured,
checkInitComplete,
downloadFiles,
handleError,
getServices,
} = require("./utils")
const { confirmation } = require("../questions")
const compose = require("docker-compose")
const { COMPOSE_PATH } = require("./makeFiles")
const { info, success } = require("../utils")
const { start } = require("./start")
const BB_COMPOSE_SERVICES = ["app-service", "worker-service", "proxy-service"]
const BB_SINGLE_SERVICE = ["budibase"]
exports.update = async () => {
const { services } = getServices(COMPOSE_PATH)
const isSingle = Object.keys(services).length === 1
await checkDockerConfigured()
checkInitComplete()
if (
!isSingle &&
(await confirmation("Do you wish to update you docker-compose.yaml?"))
) {
await downloadFiles()
}
await handleError(async () => {
const status = await compose.ps()
const parts = status.out.split("\n")
const isUp = parts[2] && parts[2].indexOf("Up") !== -1
if (isUp) {
console.log(info("Stopping services, this may take a moment."))
await compose.stop()
}
console.log(info("Beginning update, this may take a few minutes."))
let services
if (isSingle) {
services = BB_SINGLE_SERVICE
} else {
services = BB_COMPOSE_SERVICES
}
await compose.pullMany(services, { log: true })
if (isUp) {
console.log(success("Update complete, restarting services..."))
await start()
}
})
}

View File

@ -0,0 +1,87 @@
const { lookpath } = require("lookpath")
const fs = require("fs")
const makeFiles = require("./makeFiles")
const { logErrorToFile, downloadFile, error } = require("../utils")
const yaml = require("yaml")
const ERROR_FILE = "docker-error.log"
const FILE_URLS = [
"https://raw.githubusercontent.com/Budibase/budibase/master/hosting/docker-compose.yaml",
]
exports.downloadFiles = async () => {
const promises = []
for (let url of FILE_URLS) {
const fileName = url.split("/").slice(-1)[0]
promises.push(downloadFile(url, `./${fileName}`))
}
await Promise.all(promises)
}
exports.checkDockerConfigured = async () => {
const error =
"docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose"
const docker = await lookpath("docker")
const compose = await lookpath("docker-compose")
if (!docker || !compose) {
throw error
}
}
exports.checkInitComplete = () => {
if (
!fs.existsSync(makeFiles.ENV_PATH) &&
!fs.existsSync(makeFiles.COMPOSE_PATH)
) {
throw "Please run the hosting --init command before any other hosting command."
}
}
exports.handleError = async func => {
try {
await func()
} catch (err) {
if (err && err.err) {
logErrorToFile(ERROR_FILE, err.err)
}
throw `Failed to start - logs written to file: ${ERROR_FILE}`
}
}
exports.getServices = path => {
const dockerYaml = fs.readFileSync(path, "utf8")
const parsedYaml = yaml.parse(dockerYaml)
return { yaml: parsedYaml, services: parsedYaml.services }
}
exports.getAppService = path => {
const { yaml, services } = exports.getServices(path),
serviceList = Object.keys(services)
let service
if (services["app-service"]) {
service = services["app-service"]
} else if (serviceList.length === 1) {
service = services[serviceList[0]]
}
return { yaml, service }
}
exports.updateDockerComposeService = updateFn => {
const opts = ["docker-compose.yaml", "docker-compose.yml"]
const dockerFilePath = opts.find(name => fs.existsSync(name))
if (!dockerFilePath) {
console.log(error("Unable to locate docker-compose YAML."))
return
}
const { yaml: parsedYaml, service } = exports.getAppService(dockerFilePath)
if (!service) {
console.log(
error(
"Unable to locate service within compose file, is it a valid Budibase configuration?"
)
)
return
}
updateFn(service)
fs.writeFileSync(dockerFilePath, yaml.stringify(parsedYaml))
}

View File

@ -0,0 +1,34 @@
const { resolve } = require("path")
const fs = require("fs")
const { error, success } = require("../utils")
const { updateDockerComposeService } = require("./utils")
exports.watchPlugins = async pluginPath => {
const PLUGIN_PATH = "/plugins"
// get absolute path
pluginPath = resolve(pluginPath)
if (!fs.existsSync(pluginPath)) {
console.log(
error(
`The directory "${pluginPath}" does not exist, please create and then try again.`
)
)
return
}
updateDockerComposeService(service => {
// set environment variable
service.environment["PLUGINS_DIR"] = PLUGIN_PATH
// add volumes to parsed yaml
if (!service.volumes) {
service.volumes = []
}
const found = service.volumes.find(vol => vol.includes(PLUGIN_PATH))
if (found) {
service.volumes.splice(service.volumes.indexOf(found), 1)
}
service.volumes.push(`${pluginPath}:${PLUGIN_PATH}`)
})
console.log(
success(`Docker compose configured to watch directory: ${pluginPath}`)
)
}

View File

@ -1,5 +1,5 @@
const Command = require("../structures/Command")
const { CommandWords, AnalyticsEvents } = require("../constants")
const { CommandWords, AnalyticsEvents, InitTypes } = require("../constants")
const { getSkeleton, fleshOutSkeleton } = require("./skeleton")
const questions = require("../questions")
const fs = require("fs")
@ -9,6 +9,9 @@ const { runPkgCommand } = require("../exec")
const { join } = require("path")
const { success, error, info, moveDirectory } = require("../utils")
const { captureEvent } = require("../events")
const fp = require("find-free-port")
const { init: hostingInit } = require("../hosting/init")
const { start: hostingStart } = require("../hosting/start")
function checkInPlugin() {
if (!fs.existsSync("package.json")) {
@ -141,6 +144,19 @@ async function watch() {
}
}
async function dev() {
const pluginDir = await questions.string("Directory to watch", "./")
const [port] = await fp(10000)
await hostingInit({
init: InitTypes.QUICK,
single: true,
watchPluginDir: pluginDir,
genUser: true,
port,
})
await hostingStart()
}
const command = new Command(`${CommandWords.PLUGIN}`)
.addHelp(
"Custom plugins for Budibase, init, build and verify your components and datasources with this tool."
@ -160,5 +176,10 @@ const command = new Command(`${CommandWords.PLUGIN}`)
"Automatically build any changes to your plugin.",
watch
)
.addSubOption(
"--dev",
"Run a development environment which automatically watches the current directory.",
dev
)
exports.command = command

View File

@ -1,4 +1,9 @@
const { getSubHelpDescription, getHelpDescription, error } = require("../utils")
const {
getSubHelpDescription,
getHelpDescription,
error,
capitaliseFirstLetter,
} = require("../utils")
class Command {
constructor(command, func = null) {
@ -8,6 +13,15 @@ class Command {
this.func = func
}
convertToCommander(lookup) {
const parts = lookup.toLowerCase().split("-")
// camel case, separate out first
const first = parts.shift()
return [first]
.concat(parts.map(part => capitaliseFirstLetter(part)))
.join("")
}
addHelp(help) {
this.help = help
return this
@ -25,10 +39,7 @@ class Command {
command = command.description(getHelpDescription(thisCmd.help))
}
for (let opt of thisCmd.opts) {
command = command.option(
`${opt.command}`,
getSubHelpDescription(opt.help)
)
command = command.option(opt.command, getSubHelpDescription(opt.help))
}
command.helpOption(
"--help",
@ -36,17 +47,25 @@ class Command {
)
command.action(async options => {
try {
let executed = false
let executed = false,
found = false
for (let opt of thisCmd.opts) {
const lookup = opt.command.split(" ")[0].replace("--", "")
if (!executed && options[lookup]) {
let lookup = opt.command.split(" ")[0].replace("--", "")
// need to handle how commander converts watch-plugin-dir to watchPluginDir
lookup = this.convertToCommander(lookup)
found = !executed && options[lookup]
if (found && opt.func) {
const input =
Object.keys(options).length > 1 ? options : options[lookup]
await opt.func(input)
executed = true
}
}
if (!executed) {
if (found && !executed) {
console.log(
error(`${Object.keys(options)[0]} is an option, not an operation.`)
)
} else if (!executed) {
console.log(error(`Unknown ${this.command} option.`))
command.help()
}

View File

@ -33,11 +33,10 @@ class ConfigManager {
}
setValue(key, value) {
const updated = {
this.config = {
...this.config,
[key]: value,
}
this.config = updated
}
removeKey(key) {

View File

@ -84,3 +84,15 @@ exports.moveDirectory = (oldPath, newPath) => {
}
fs.rmdirSync(oldPath)
}
exports.capitaliseFirstLetter = str => {
return str.charAt(0).toUpperCase() + str.slice(1)
}
exports.stringifyToDotEnv = json => {
let str = ""
for (let [key, value] of Object.entries(json)) {
str += `${key}=${value}\n`
}
return str
}

View File

@ -1113,6 +1113,11 @@ fill-range@^7.0.1:
dependencies:
to-regex-range "^5.0.1"
find-free-port@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/find-free-port/-/find-free-port-2.0.0.tgz#4b22e5f6579eb1a38c41ac6bcb3efed1b6da9b1b"
integrity sha512-J1j8gfEVf5FN4PR5w5wrZZ7NYs2IvqsHcd03cAeQx3Ec/mo+lKceaVNhpsRKoZpZKbId88o8qh+dwUwzBV6WCg==
find-replace@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
@ -3080,6 +3085,11 @@ yallist@^4.0.0:
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
yaml@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.1.1.tgz#1e06fb4ca46e60d9da07e4f786ea370ed3c3cfec"
integrity sha512-o96x3OPo8GjWeSLF+wOAbrPfhFOGY0W00GNaxCDv+9hkcDJEnev1yh8S7pgHF0ik6zc8sQLuL8hjHjJULZp8bw==
yargs-parser@^20.2.2:
version "20.2.9"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"