Compare commits

...

8 Commits

Author SHA1 Message Date
hubert a4add8adbc Update dependencies 2023-05-07 18:00:44 +02:00
hubert 8ad1e8d884 Auto discover GRAALVM_HOME 2023-05-07 17:36:39 +02:00
hubert 37dba17074 Upgrade gradle + jdk 2023-05-07 16:56:34 +02:00
hubert e12cb1cac7 Simplify js api 2021-04-03 22:34:16 +02:00
hubert 907fdb4f10 Clean build 2021-04-03 22:24:34 +02:00
hubert f037c6a724 Fix promptBoolean + Update build script 2021-04-02 19:25:01 +02:00
hubert 6ed31db7dc Replace toml config with a javascript file 2021-04-02 18:16:27 +02:00
hubert 6e1e82c2c5 Expand globs for templates + files & Add default prompt value 2021-04-02 12:17:59 +02:00
34 changed files with 453 additions and 214 deletions
+3 -4
View File
@@ -12,9 +12,8 @@ Move the executable somewhere in your $PATH
Or build a native image with GraalVM
```bash
./gradlew installShadowDist
cd app/build/install/app-shadow/lib
native-image --no-fallback -R:MaxNewSize=32 -jar scaffold.jar
./gradlew buildNative
cd app/build/native
```
## Usage
@@ -28,7 +27,7 @@ cd generator-directory
You can then add [Pebble templates](https://pebbletemplates.io/) and files in the tree directory
An example is present in this repo
An kotlin example is present [here](https://git.simplenotes.be/hubert/Kotlin-scaffold)
### List generators
+10 -13
View File
@@ -1,24 +1,21 @@
plugins {
id("kotlin-application")
id("com.github.johnrengelman.shadow") version "6.1.0"
id("shadow")
id("native-image")
id("release")
}
version = "0.0.1-SNAPSHOT"
dependencies {
implementation("org.slf4j:slf4j-api:2.0.0-alpha1")
runtimeOnly("org.slf4j:slf4j-simple:2.0.0-alpha1")
implementation("io.pebbletemplates:pebble:3.1.5")
implementation("com.github.ajalt.clikt:clikt:3.1.0")
implementation("com.electronwill.night-config:toml:3.6.3")
implementation("org.slf4j:slf4j-api:2.0.7")
runtimeOnly("org.slf4j:slf4j-simple:2.0.7")
implementation("io.pebbletemplates:pebble:3.2.1")
implementation("com.github.ajalt.clikt:clikt:3.5.2")
implementation("org.graalvm.js:js:22.3.2")
}
application {
mainClassName = "scaffold.ScaffoldKt"
mainClass.set("scaffold.ScaffoldKt")
applicationName = "scaffold"
}
tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
archiveBaseName.set("scaffold")
archiveClassifier.set("")
archiveVersion.set("")
}
@@ -1,5 +1,6 @@
[
{
"name":"com.electronwill.nightconfig.toml.TomlFormat"
"name":"java.util.Map$Entry",
"allPublicMethods":true
}
]
@@ -1,5 +1,11 @@
{
"resources": {
"includes":[{"pattern":"\\QMETA-INF/services/org.slf4j.spi.SLF4JServiceProvider\\E"}]},
"includes": [
{"pattern": "\\QMETA-INF/services/org.slf4j.spi.SLF4JServiceProvider\\E"},
{"pattern": "\\Qinit/index.d.ts\\E"},
{"pattern": "\\Qinit/index.js\\E"},
{"pattern": "\\Qinit/README.md\\E"}
]
},
"bundles": []
}
+1
View File
@@ -0,0 +1 @@
# {{ name }}
+43
View File
@@ -0,0 +1,43 @@
/**
* Print a message to the console
*
* @param message
*/
declare function echo(message: string): void
declare interface prompt {
/**
* Prompt a user for text input
*
* @param text - The text to display for the prompt
* @param def - A default value
*/
string(text: string, def?: string): string
/**
* Prompt a user for text input
*
* @param text - The text to display for the prompt
* @param def - A default value
*/
boolean(text: string, def?: boolean): boolean
}
/**
* Renders a template
*
* @param name - The template name
* @param context - The template context
* @param output - The output path, if absent, default to the template name
*/
declare function render(name: string, context: any, output?: string): string
/**
* Copy a file
*
* @param input - The input path
* @param output - The output path, if absent, default to the input path
*/
declare function copy(input: string, output?: string): void
+7
View File
@@ -0,0 +1,7 @@
echo("Example")
const name = prompt.string("Project name")
const ctx = {name: name}
render("README.md", ctx)
-46
View File
@@ -1,46 +0,0 @@
package scaffold
import com.electronwill.nightconfig.core.Config
import com.electronwill.nightconfig.core.UnmodifiableConfig
import com.electronwill.nightconfig.core.file.FileConfig
import java.nio.file.Path
data class GeneratorConfig(
val templates: List<String>,
val files: List<Path>,
val prompts: Map<String, List<PromptValue>>
)
fun Generator.loadConfig(): GeneratorConfig {
val config: UnmodifiableConfig = FileConfig.of(rootPath.resolve("config.toml")).apply { load() }
val templates = config.get<List<String>>("templates")
val prompts = mutableMapOf<String, MutableList<PromptValue>>()
val section = config.get<Any?>("prompt") ?: error("Missing prompt section")
if (section !is Config) error("Prompt section is not an object")
for ((subsectionName, subsection) in section.valueMap()) {
check(subsection is List<*>) { "prompts.$subsectionName is not a list of prompts" }
for (prompt in subsection) {
check(prompt is Config)
val promptValue = PromptValue(
name = prompt.get("name") ?: error("Missing name"),
info = prompt.get("info"),
type = prompt.getEnum("type", PromptType::class.java) ?: PromptType.String
)
val current = prompts.computeIfAbsent(subsectionName) { ArrayList() }
current += promptValue
}
}
val files = config.get<List<String>>("files").flatMap { path ->
if ("*" in path) FileGlob.listFiles(treeRoot, path)
else listOf(treeRoot.resolve(path))
}
return GeneratorConfig(templates, files, prompts)
}
-22
View File
@@ -1,22 +0,0 @@
package scaffold
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
object FileGlob {
fun listFiles(root: Path, glob: String): MutableList<Path> {
val matcher = FileSystems.getDefault().getPathMatcher("glob:$root/$glob")
val matches = mutableListOf<Path>()
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes?): FileVisitResult {
if (matcher.matches(file)) matches.add(file)
return FileVisitResult.CONTINUE
}
})
return matches
}
}
+4 -5
View File
@@ -5,13 +5,12 @@ import java.nio.file.Path
import kotlin.streams.toList
class Generators(val configDirectory: Path) {
fun isValid(name: String): Boolean {
val generatorRoot = configDirectory.resolve(name)
return Files.isDirectory(generatorRoot) && Files.isRegularFile(generatorRoot.resolve("config.toml"))
}
fun isValid(name: String) = isValid(configDirectory.resolve(name))
private fun isValid(path: Path) = Files.isDirectory(path) && Files.isRegularFile(path.resolve("index.js"))
fun findAll(): List<Path> = Files
.list(configDirectory)
.filter { Files.exists(it.resolve("config.toml")) }
.filter(::isValid)
.toList()
}
+38
View File
@@ -0,0 +1,38 @@
package scaffold
import com.github.ajalt.clikt.core.UsageError
import com.github.ajalt.clikt.output.CliktConsole
import com.github.ajalt.clikt.output.TermUi
import com.github.ajalt.clikt.output.defaultCliktConsole
@Suppress("DEPRECATION") // TODO: later..
class Prompt(private val console: CliktConsole = defaultCliktConsole()) {
fun string(text: String, default: String?): String = TermUi.prompt(
text = text,
default = default,
console = console
) { it }!!
fun boolean(text: String, default: Boolean?): Boolean {
val (defaultString, suffix) = when (default) {
true -> "y" to "[Y/n]"
false -> "n" to "[y/N]"
null -> null to "[y/n]"
}
return TermUi.prompt(
"$text $suffix",
default = defaultString,
showDefault = false,
console = console
) {
when (it.lowercase()) {
"y" -> true
"n" -> false
else -> throw UsageError("Can only be [y/n]")
}
}!!
}
}
-9
View File
@@ -1,9 +0,0 @@
package scaffold
enum class PromptType { String }
data class PromptValue(
val name: String,
val info: String?,
val type: PromptType,
)
+4 -4
View File
@@ -19,11 +19,11 @@ fun main(args: Array<String>) {
}
val scaffoldConfigDirectory = configDirectory.resolve("scaffold")
val generatorService = Generators(scaffoldConfigDirectory)
val generators = Generators(scaffoldConfigDirectory)
Scaffold().subcommands(
ListCommand(generatorService),
GenerateCommand(generatorService),
NewCommand(generatorService)
ListCommand(generators),
GenerateCommand(generators),
NewCommand(generators),
).main(args)
}
+27 -33
View File
@@ -6,39 +6,26 @@ import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.convert
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required
import com.mitchellbosecke.pebble.PebbleEngine
import com.mitchellbosecke.pebble.loader.FileLoader
import com.mitchellbosecke.pebble.template.PebbleTemplate
import io.pebbletemplates.pebble.PebbleEngine
import io.pebbletemplates.pebble.loader.FileLoader
import io.pebbletemplates.pebble.template.PebbleTemplate
import scaffold.Generator
import scaffold.Generators
import scaffold.PromptType
import scaffold.loadConfig
import scaffold.Prompt
import scaffold.scripting.ScriptContext
import scaffold.scripting.ScriptEngine
import java.io.StringWriter
import java.nio.file.Files
import java.nio.file.Path
class GenerateCommand(private val generators: Generators) : CliktCommand("generate") {
private val name by argument(help = "generator name")
private val output by option().convert { Path.of(it) }.required()
private val outputPathRoot by option("-o", "--output").convert { Path.of(it) }.required()
override fun run() {
checkPreconditions()
val generator = Generator(generators.configDirectory, name)
val config = generator.loadConfig()
val templateContext = mutableMapOf<String, Any?>()
for ((path, prompts) in config.prompts) {
val map = mutableMapOf<String, Any>()
templateContext[path] = map
for ((name, info, type) in prompts) {
if (type != PromptType.String) TODO()
val value = prompt(info ?: name)!!
map[name] = value
}
}
val pebble = PebbleEngine.Builder()
.autoEscaping(false)
@@ -46,29 +33,36 @@ class GenerateCommand(private val generators: Generators) : CliktCommand("genera
.loader(FileLoader().apply { prefix = generator.treeRoot.toString() })
.build()!!
for (template in config.templates) {
val renderedTemplate = pebble.getTemplate(template)(templateContext)
val outputPath = output.resolve(template)
val scriptContext = object : ScriptContext {
override fun echo(message: String) = this@GenerateCommand.echo(message)
override val prompt = Prompt()
override fun render(template: String, ctx: Map<String, Any?>, output: String?) {
val renderedTemplate = pebble.getTemplate(template)(ctx)
val outputPath = output?.let { outputPathRoot.resolve(it) } ?: outputPathRoot.resolve(template)
Files.createDirectories(outputPath.parent)
Files.writeString(outputPath, renderedTemplate)
}
for (inputFile in config.files) {
val relativeFile = generator.treeRoot.relativize(inputFile)
val outputFile = output.resolve(relativeFile)
Files.createDirectories(outputFile.parent)
Files.copy(inputFile, outputFile)
override fun copy(input: String, output: String?) {
val inputPath = generator.treeRoot.resolve(input)
val outputPath = output?.let { outputPathRoot.resolve(it) } ?: outputPathRoot.resolve(input)
Files.createDirectories(outputPath.parent)
Files.copy(inputPath, outputPath)
}
}
echo("Generated project in $output")
val engine = ScriptEngine(scriptContext)
val script = Files.readString(generator.rootPath.resolve("index.js"))
engine.eval(script)
}
private fun checkPreconditions() {
if (Files.exists(output)) {
if (Files.list(output).findAny().isPresent) {
echo("Output path `$output` is not empty")
if (Files.exists(outputPathRoot)) {
if (Files.list(outputPathRoot).findAny().isPresent) {
echo("Output path `$outputPathRoot` is not empty")
throw ProgramResult(1)
}
}
+11 -1
View File
@@ -4,5 +4,15 @@ import com.github.ajalt.clikt.core.CliktCommand
import scaffold.Generators
class ListCommand(private val generators: Generators) : CliktCommand("list") {
override fun run() = echo(generators.findAll().joinToString(" ") { it.last().toString() })
override fun run() {
val generators = generators.findAll()
if (generators.isEmpty()) {
echo("No generators yet, you can create one with `scaffold new`")
} else {
generators.forEach {
echo(it.last())
}
}
}
}
+4 -18
View File
@@ -19,29 +19,15 @@ class NewCommand(private val generators: Generators) : CliktCommand("new") {
Files.createDirectories(root)
val configFile = """
templates = [
"README.md"
]
fun resource(path: String) = javaClass.getResource("/$path").readText()
files = [
".gitignore"
]
Files.writeString(root.resolve("index.js"), resource("init/index.js"))
Files.writeString(root.resolve("index.d.ts"), resource("init/index.d.ts"))
[prompt]
[[prompt.project]]
type = "String"
name = "name"
info = "Project name"
""".trimIndent()
Files.writeString(root.resolve("config.toml"), configFile)
val tree = root.resolve("tree")
Files.createDirectory(tree)
Files.writeString(tree.resolve("README.md"), "# {{ project.name }}")
Files.writeString(tree.resolve(".gitignore"), "# https://git-scm.com/docs/gitignore")
Files.writeString(tree.resolve("README.md"), resource("init/README.md"))
echo("Generator created in $root")
}
+26
View File
@@ -0,0 +1,26 @@
package scaffold.scripting
import org.graalvm.polyglot.Value
import org.graalvm.polyglot.proxy.ProxyExecutable
import org.graalvm.polyglot.proxy.ProxyObject
import scaffold.Prompt
class PromptProxyAdapter(private val prompt: Prompt) : ProxyObject {
override fun getMember(key: String?) = when (key) {
"string" -> ProxyExecutable { args ->
prompt.string(args[0].asString(), args.getOrNull(1)?.asString())
}
"boolean" -> ProxyExecutable { args ->
prompt.boolean(args[0].asString(), args.getOrNull(1)?.asBoolean())
}
else -> throw UnsupportedOperationException()
}
override fun getMemberKeys() = arrayOf("string", "boolean")
override fun hasMember(key: String?): Boolean = key in memberKeys
override fun putMember(key: String?, value: Value?) = throw UnsupportedOperationException()
}
+13
View File
@@ -0,0 +1,13 @@
package scaffold.scripting
import scaffold.Prompt
interface ScriptContext {
fun echo(message: String)
val prompt: Prompt
fun render(template: String, ctx: Map<String, Any?>, output: String?)
fun copy(input: String, output: String?)
}
+42
View File
@@ -0,0 +1,42 @@
package scaffold.scripting
import org.graalvm.polyglot.Context
import org.graalvm.polyglot.proxy.ProxyExecutable
import org.intellij.lang.annotations.Language
class ScriptEngine(private val scriptContext: ScriptContext) {
private val context = Context
.newBuilder("js")
.allowIO(true)
.build()
init {
with(context.getBindings("js")) {
putMember("echo", ProxyExecutable { (message) ->
scriptContext.echo(message.asString())
null
})
putMember("prompt", PromptProxyAdapter(scriptContext.prompt))
putMember("render", ProxyExecutable { args ->
@Suppress("UNCHECKED_CAST")
scriptContext.render(
args[0].asString(),
args[1].`as`(Map::class.java) as Map<String, Any?>,
args.getOrNull(2)?.asString()
)
})
putMember("copy", ProxyExecutable { args ->
scriptContext.copy(args[0].asString(), args.getOrNull(1)?.asString())
})
}
}
fun eval(@Language("js") script: CharSequence) {
context.eval("js", script)
}
}
+60
View File
@@ -0,0 +1,60 @@
@file:Suppress("MemberVisibilityCanBePrivate")
package scaffold
import com.github.ajalt.clikt.output.CliktConsole
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import strikt.api.expectThat
import strikt.assertions.isEqualTo
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PromptTest {
val console = mockk<CliktConsole>()
val prompt = Prompt(console)
@BeforeEach
fun beforeEach() = clearMocks(console)
@CsvSource(
value = [
"null, answer, answer",
"def, answer, answer",
"def, '', def"
]
)
@ParameterizedTest(name = "prompt string({argumentsWithNames})")
fun `prompt string`(default: String?, answer: String, expectedValue: String) {
every { console.promptForLine(any(), any()) } returns answer
expectThat(prompt.string("test", default)).isEqualTo(expectedValue)
}
@CsvSource(
value = [
"null, y, true",
"null, n, false",
"true, '', true",
"true, y, true",
"true, n, false",
"false, '', false",
"false, y, true",
"false, n, false",
]
)
@ParameterizedTest(name = "prompt boolean({argumentsWithNames})")
fun `prompt boolean`(default: Boolean?, answer: String, expectedValue: Boolean) {
every { console.promptForLine(any(), any()) } returns answer
expectThat(prompt.boolean("test", default)).isEqualTo(expectedValue)
}
}
+54
View File
@@ -0,0 +1,54 @@
@file:Suppress("MemberVisibilityCanBePrivate")
package scaffold.scripting
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle
import scaffold.Prompt
@TestInstance(Lifecycle.PER_CLASS)
class ScriptEngineTest {
val scriptContext = mockk<ScriptContext>().also {
every { it.prompt } returns Prompt()
}
val scriptEngine = ScriptEngine(scriptContext)
@BeforeEach
fun beforeEach() = clearMocks(scriptContext)
@Test
fun echo() {
every { scriptContext.echo("test") } returns Unit
val script = "echo('test')"
scriptEngine.eval(script)
verify { scriptContext.echo(any()) }
}
@Test
fun render() {
every { scriptContext.render("test", match { it["something"] == "hello" }, null) } returns Unit
val script = """
const ctx = {
"something": "hello"
}
render("test", ctx)
""".trimIndent()
scriptEngine.eval(script)
verify { scriptContext.render(any(), any(), any()) }
}
}
+3 -6
View File
@@ -2,15 +2,12 @@ plugins {
`kotlin-dsl`
}
kotlinDslPluginOptions {
experimentalWarning.set(false)
}
repositories {
gradlePluginPortal()
}
dependencies {
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.4.31"))
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.31")
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.21"))
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.21")
implementation("com.github.johnrengelman:shadow:8.1.1")
}
@@ -4,16 +4,12 @@ plugins {
repositories {
mavenCentral()
maven {
url = uri("https://kotlin.bintray.com/kotlinx")
// https://github.com/Kotlin/kotlinx.html/issues/173
content { includeModule("org.jetbrains.kotlinx", "kotlinx-html-jvm") }
}
}
java {
targetCompatibility = JavaVersion.toVersion(11)
sourceCompatibility = JavaVersion.toVersion(11)
toolchain {
languageVersion.set(JavaLanguageVersion.of(19))
}
}
tasks.withType<JavaCompile> {
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
@@ -8,7 +10,9 @@ plugins {
dependencies {
implementation(kotlin("stdlib-jdk8"))
implementation(platform(kotlin("bom")))
testImplementation("io.kotest:kotest-runner-junit5:4.4.1")
testImplementation("org.junit.jupiter:junit-jupiter:5.7.1")
testImplementation("io.strikt:strikt-core:0.30.0")
testImplementation("io.mockk:mockk:1.10.6")
}
tasks.withType<Test> {
@@ -16,17 +20,14 @@ tasks.withType<Test> {
}
tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = "11"
javaParameters = true
freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn")
compilerOptions {
jvmTarget.set(JvmTarget.JVM_19)
javaParameters.set(true)
languageVersion.set(KotlinVersion.KOTLIN_2_0)
}
}
kotlin {
sourceSets.all {
languageSettings.enableLanguageFeature("InlineClasses")
}
sourceSets["main"].kotlin.setSrcDirs(listOf("src"))
sourceSets["test"].kotlin.setSrcDirs(listOf("test"))
}
@@ -0,0 +1,54 @@
import org.gradle.jvm.toolchain.JavaToolchainService;
import java.io.ByteArrayOutputStream
task("buildNative") {
dependsOn("installShadowDist")
outputs.file("${buildDir}/native/scaffold")
doLast {
val graalvmHome = project.extensions.findByType<JavaToolchainService>()?.launcherFor {
languageVersion.set(JavaLanguageVersion.of(19))
vendor.set(JvmVendorSpec.GRAAL_VM)
}
?.orNull
?.executablePath?.asFile?.toPath()?.parent?.parent?.toString()
?: System.getenv("GRAALVM_HOME")
?: error("GRAALVM_HOME is not set")
val out = ByteArrayOutputStream()
exec {
commandLine(
"${graalvmHome}/bin/gu",
"list",
"-v",
)
standardOutput = out
}
val installedComponents = out.toString().lines()
.filter { it.startsWith("ID") }
.map { it.substringAfter(':').trim() }
if ("native-image" !in installedComponents) {
throw GradleException("GRAALVM: Missing js component")
}
if ("js" !in installedComponents) {
throw GradleException("GRAALVM: Missing js component")
}
exec {
commandLine(
"${graalvmHome}/bin/native-image",
"--no-fallback",
"-R:MaxNewSize=32",
"--language:js",
"-jar",
"${buildDir}/install/app-shadow/lib/scaffold.jar",
"${buildDir}/native/scaffold"
)
}
}
}
@@ -0,0 +1,7 @@
tasks.register<Zip>("release") {
dependsOn("buildNative")
archiveFileName.set("scaffold-${archiveVersion.get()}-linux.zip")
destinationDirectory.set(file("$buildDir/release"))
from("$buildDir/native/scaffold")
}
@@ -0,0 +1,11 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
id("com.github.johnrengelman.shadow")
}
tasks.withType<ShadowJar> {
archiveBaseName.set("scaffold")
archiveClassifier.set("")
archiveVersion.set("")
}
-15
View File
@@ -1,15 +0,0 @@
templates = [
"README.md"
]
files = [
".gitignore",
".gitattributes"
]
[prompt]
[[prompt.project]]
type = "String"
name = "name"
info = "Project name"
-6
View File
@@ -1,6 +0,0 @@
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# These are explicitly windows files and should use crlf
*.bat text eol=crlf
-2
View File
@@ -1,2 +0,0 @@
.gradle
build
-7
View File
@@ -1,7 +0,0 @@
# {{ project.name }}
## Usage
```bash
./gradlew run
```
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
Binary file not shown.
+2 -1
View File
@@ -1,5 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists