From 5cc3d6a700bac90a1e7f1b9378a6cc31eab173fc Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:18:21 +0700 Subject: [PATCH 1/2] Generate library modules from add module --- .../com/composables/cli/CliIntegrationTest.kt | 183 +++++++++++++ cli/src/jvmMain/kotlin/Cli.kt | 252 ++++++++++++++++-- 2 files changed, 412 insertions(+), 23 deletions(-) diff --git a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt index 2717f51..d9cd877 100644 --- a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt +++ b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt @@ -396,6 +396,8 @@ class CliIntegrationTest { "add", "module", nestedModulePath, + "--type", + "app", "--package", nestedPackage, "--app-name", @@ -506,6 +508,187 @@ class CliIntegrationTest { } } + @Test + fun `cli add module creates a flat library module that compiles`() { + val rootDir = createTempRoot("composables-cli-add-library-module") + try { + val isWindows = System.getProperty("os.name").startsWith("Windows") + val projectName = if (isWindows) "s" else "sample-app" + val rootPackage = if (isWindows) "c.e.s" else "com.example.sampleapp" + val rootAppName = if (isWindows) "S" else "Sample App" + val libraryModulePath = if (isWindows) "l" else "screen-templates" + val libraryPackage = if (isWindows) "c.e.l" else "com.example.templates" + val libraryPackagePath = libraryPackage.replace(".", "/") + val libraryModuleGradlePath = ":" + libraryModulePath.replace('/', ':') + val projectDir = File(rootDir, projectName) + val launcher = installedLauncher() + + val initResult = + runProcess( + command = + listOf( + launcher.absolutePath, + "init", + projectDir.absolutePath, + "--package", + rootPackage, + "--app-name", + rootAppName, + "--targets", + "android,jvm,wasm", + ), + workingDir = rootDir, + timeoutSeconds = 60, + ) + + assertThat(initResult.finished).isTrue() + assertThat(initResult.exitCode).isEqualTo(0) + + rewriteProjectToUiStyleConventions(projectDir) + val rootBuildBeforeAdd = File(projectDir, "build.gradle.kts").readText() + val versionCatalogBeforeAdd = File(projectDir, "gradle/libs.versions.toml").readText() + + val addResult = + runProcess( + command = + listOf( + launcher.absolutePath, + "add", + "module", + libraryModulePath, + "--type", + "library", + "--package", + libraryPackage, + "--targets", + "jvm,wasm", + ), + workingDir = projectDir, + timeoutSeconds = 60, + ) + + assertThat(addResult.finished).isTrue() + assertThat(addResult.exitCode).isEqualTo(0) + assertThat(addResult.output).contains("Library Module Configuration") + assertThat(addResult.output).doesNotContain("App Name") + + val libraryModuleDir = File(projectDir, libraryModulePath) + assertThat(File(libraryModuleDir, "build.gradle.kts").exists()).isTrue() + assertThat( + File(libraryModuleDir, "src/commonMain/kotlin/$libraryPackagePath/Library.kt") + .exists()) + .isTrue() + assertThat(File(libraryModuleDir, "shared").exists()).isFalse() + assertThat(File(libraryModuleDir, "desktopApp").exists()).isFalse() + assertThat(File(libraryModuleDir, "webApp").exists()).isFalse() + + val settingsContent = File(projectDir, "settings.gradle.kts").readText() + assertThat(settingsContent).contains("""include("$libraryModuleGradlePath")""") + assertThat(File(projectDir, "build.gradle.kts").readText()).isEqualTo(rootBuildBeforeAdd) + assertThat(File(projectDir, "gradle/libs.versions.toml").readText()) + .isEqualTo(versionCatalogBeforeAdd) + + val libraryBuildFile = File(libraryModuleDir, "build.gradle.kts").readText() + assertThat(libraryBuildFile).contains("alias(libs.plugins.kotlin.multiplatform)") + assertThat(libraryBuildFile).contains("alias(libs.plugins.compose)") + assertThat(libraryBuildFile).contains("alias(libs.plugins.compose.compiler)") + assertThat(libraryBuildFile).contains("implementation(libs.composables.ui)") + assertThat(libraryBuildFile).contains("implementation(libs.composables.uri.painter)") + assertThat(libraryBuildFile).contains("jvm()") + assertThat(libraryBuildFile).contains("wasmJs") + assertThat(libraryBuildFile).doesNotContain("binaries.executable") + + if (isWindows) { + val evaluationResult = + runProcess( + command = projectGradleCommand("$libraryModuleGradlePath:tasks", "--all"), + workingDir = projectDir, + timeoutSeconds = 180, + ) + + assertThat(evaluationResult.finished).isTrue() + assertThat(evaluationResult.exitCode).isEqualTo(0) + assertThat(evaluationResult.output).contains("compileKotlinJvm") + } else { + val compileResult = + runProcess( + command = + projectGradleCommand( + "$libraryModuleGradlePath:compileKotlinJvm", + "$libraryModuleGradlePath:compileKotlinWasmJs", + ), + workingDir = projectDir, + timeoutSeconds = 180, + ) + + assertThat(compileResult.finished).isTrue() + assertThat(compileResult.exitCode).isEqualTo(0) + assertThat(compileResult.output).contains("BUILD SUCCESSFUL") + } + } finally { + rootDir.deleteRecursively() + } + } + + @Test + fun `cli add module prompts for module type interactively`() { + val rootDir = createTempRoot("composables-cli-add-module-interactive") + try { + val projectDir = File(rootDir, "sample-app") + val launcher = installedLauncher() + + val initResult = + runProcess( + command = + listOf( + launcher.absolutePath, + "init", + projectDir.absolutePath, + "--package", + "com.example.sampleapp", + "--app-name", + "Sample App", + "--targets", + "jvm,wasm", + ), + workingDir = rootDir, + timeoutSeconds = 60, + ) + + assertThat(initResult.finished).isTrue() + assertThat(initResult.exitCode).isEqualTo(0) + + val addResult = + runProcess( + command = listOf(launcher.absolutePath, "add", "module"), + workingDir = projectDir, + stdin = + """ + screen-templates + 2 + com.example.templates + n + y + n + y + """ + .trimIndent() + "\n", + timeoutSeconds = 60, + ) + + assertThat(addResult.finished).isTrue() + assertThat(addResult.exitCode).isEqualTo(0) + assertThat(addResult.output).contains("What type of module do you want to add?") + assertThat(addResult.output).contains("Library Module Configuration") + assertThat(File(projectDir, "screen-templates/build.gradle.kts").exists()).isTrue() + assertThat(File(projectDir, "screen-templates/shared").exists()).isFalse() + assertThat(File(projectDir, "settings.gradle.kts").readText()) + .contains("""include(":screen-templates")""") + } finally { + rootDir.deleteRecursively() + } + } + @Test fun `rewriteProjectToUiStyleConventions removes type safe project accessors on CRLF settings files`() { val rootDir = createTempRoot("composables-cli-ui-style-crlf") diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index 50c0d48..a0bfdc8 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -41,6 +41,11 @@ val IOS_APP_MODULE = "iosApp" val DESKTOP_APP_MODULE = "desktopApp" val WEB_APP_MODULE = "webApp" +private enum class AddModuleType(val id: String, val displayName: String) { + App("app", "App"), + Library("library", "Library"), +} + private fun File.toResourcePath(): String = invariantSeparatorsPath.replace('\\', '/') private fun normalizeTargets(targets: Set): LinkedHashSet = @@ -462,14 +467,13 @@ class InstallMcp : CliktCommand("mcp") { class AddModule : CliktCommand("module") { override fun help(context: Context): String = """ - Adds a new Compose app module group to the current Gradle project. + Adds a new Compose module to the current Gradle project. """ .trimIndent() - private val path by - argument("path", help = "The path to create the new module group in").optional() - private val packageName by - option("--package", help = "The package name for the generated app module group") + private val path by argument("path", help = "The path to create the new module in").optional() + private val moduleTypeInput by option("--type", help = "Module type: app or library") + private val packageName by option("--package", help = "The package name for the generated module") private val appName by option("--app-name", help = "The display name for the generated app") private val targetsInput by option("--targets", help = "Comma-separated targets: android,jvm,ios,wasm") @@ -489,33 +493,51 @@ class AddModule : CliktCommand("module") { } val anyExplicitInput = - path != null || packageName != null || appName != null || targetsInput != null || overwrite + path != null || + moduleTypeInput != null || + packageName != null || + appName != null || + targetsInput != null || + overwrite val target: File + val moduleType: AddModuleType val resolvedPackageName: String - val resolvedAppName: String + val resolvedAppName: String? val targets: Set if (!anyExplicitInput) { try { target = readNewModuleDirectory(projectRoot) + moduleType = readAddModuleType() resolvedPackageName = readNamespace() - resolvedAppName = readAppName() + resolvedAppName = + when (moduleType) { + AddModuleType.App -> readAppName() + AddModuleType.Library -> null + } targets = readTargets() } catch (error: RuntimeException) { if (error::class.simpleName == "ReadAfterEOFException" || error.message?.contains("EOF has already been reached") == true) { throw UsageError( - "Interactive mode requires stdin. Pass , --package, --app-name, and --targets for non-interactive use.", + "Interactive mode requires stdin. Pass , --type, --package, and --targets for non-interactive use.", ) } throw error } } else { + moduleType = + try { + parseAddModuleType(moduleTypeInput) + } catch (error: IllegalArgumentException) { + throw UsageError(error.message ?: "Invalid module type") + } val missingInputs = buildList { if (path == null) add("") + if (moduleTypeInput == null) add("--type") if (packageName == null) add("--package") - if (appName == null) add("--app-name") + if (moduleType == AddModuleType.App && appName == null) add("--app-name") if (targetsInput == null) add("--targets") } if (missingInputs.isNotEmpty()) { @@ -524,14 +546,18 @@ class AddModule : CliktCommand("module") { } resolvedPackageName = packageName!! - resolvedAppName = appName!! + resolvedAppName = appName if (!isValidPackageName(resolvedPackageName)) { throw UsageError( "Invalid package name. Must be a valid Java package name (e.g., com.example.app)") } - if (!isValidAppName(resolvedAppName)) { + if (moduleType == AddModuleType.Library && appName != null) { + throw UsageError("--app-name is only supported for app modules") + } + + if (moduleType == AddModuleType.App && !isValidAppName(resolvedAppName!!)) { throw UsageError("Invalid app name. Must contain at least one letter or digit") } @@ -553,24 +579,41 @@ class AddModule : CliktCommand("module") { val modulePath = toRelativeModulePath(projectRoot, target) val conventions = inferProjectConventions(projectRoot, targets) val includedModules = - createModuleGroup( - projectRoot = projectRoot, - appRootDir = target, - packageName = resolvedPackageName, - appName = resolvedAppName, - targets = targets, - conventions = conventions, - ) + when (moduleType) { + AddModuleType.App -> + createModuleGroup( + projectRoot = projectRoot, + appRootDir = target, + packageName = resolvedPackageName, + appName = resolvedAppName!!, + targets = targets, + conventions = conventions, + ) + AddModuleType.Library -> + listOf( + createLibraryModule( + projectRoot = projectRoot, + moduleDir = target, + packageName = resolvedPackageName, + targets = targets, + conventions = conventions, + ), + ) + } addModuleToSettings(projectRoot, includedModules) infoln { "" } - infoln { "App Module Configuration:" } - infoln { "\tApp Name: $resolvedAppName" } + infoln { "${moduleType.displayName} Module Configuration:" } + if (resolvedAppName != null) { + infoln { "\tApp Name: $resolvedAppName" } + } infoln { "\tPackage: $resolvedPackageName" } infoln { "\tModule: $modulePath" } infoln { "\tTargets: ${targets.joinToString(", ")}" } infoln { "" } - debugln { "Success! Your new Compose app module group is ready at ${target.absolutePath}" } + debugln { + "Success! Your new Compose ${moduleType.id} module is ready at ${target.absolutePath}" + } } } @@ -838,6 +881,40 @@ private fun toRelativeModulePath(projectRoot: File, target: File): String = it.toString() } +private fun readAddModuleType(): AddModuleType { + while (true) { + println("What type of module do you want to add?") + println("1. App") + println("2. Library") + print("Select module type (1-2, default: app): ") + val input = readln().trim().lowercase() + + when (input) { + "", + "1", + "app" -> return AddModuleType.App + "2", + "library", + "lib" -> return AddModuleType.Library + else -> println("Invalid module type. Enter app or library.") + } + } +} + +private fun parseAddModuleType(input: String?): AddModuleType { + val normalized = input?.trim()?.lowercase().orEmpty() + return when (normalized) { + AddModuleType.App.id -> AddModuleType.App + AddModuleType.Library.id, + "lib" -> AddModuleType.Library + "" -> + throw IllegalArgumentException("Module type is required. Use --type app or --type library") + else -> + throw IllegalArgumentException( + "Unknown module type '$input'. Available types: app, library") + } +} + internal fun readNewAppDirectory(workingDir: String): File { while (true) { print("Enter project directory: ") @@ -2887,6 +2964,135 @@ private fun addModuleToSettings( settingsFile.writeText(existingContent.trimEnd() + "\n" + newStatements.joinToString("\n") + "\n") } +private fun createLibraryModule( + projectRoot: File, + moduleDir: File, + packageName: String, + targets: Set, + conventions: ProjectConventions, +): String { + val normalizedTargets = normalizeTargets(targets) + val modulePath = toRelativeModulePath(projectRoot, moduleDir) + val moduleName = moduleDir.name + val imports = buildList { + if (normalizedTargets.contains(ANDROID)) add("import org.jetbrains.kotlin.gradle.dsl.JvmTarget") + if (normalizedTargets.contains(WASM)) + add("import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl") + } + val plugins = buildList { + add(" alias(${conventions.kotlinMultiplatformPlugin})") + add(" alias(${conventions.composePlugin})") + add(" alias(${conventions.composeCompilerPlugin})") + if (normalizedTargets.contains(ANDROID)) { + add(" alias(${conventions.androidKotlinMultiplatformLibraryPlugin})") + } + } + val kotlinTargets = buildList { + if (normalizedTargets.contains(ANDROID)) { + add( + """ android { + namespace = "$packageName" + compileSdk = ${conventions.androidCompileSdkExpression} + minSdk = ${conventions.androidMinSdkExpression} + withJava() + androidResources { + enable = true + } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + }""", + ) + } + if (normalizedTargets.contains(IOS)) { + add( + """ listOf( + iosArm64(), + iosSimulatorArm64(), + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "${toCamelCase(moduleName)}" + isStatic = true + } + }""", + ) + } + if (normalizedTargets.contains(JVM)) { + add(" jvm()") + } + if (normalizedTargets.contains(WASM)) { + add( + """ @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + }""", + ) + } + } + + moduleDir.mkdirs() + File(moduleDir, "build.gradle.kts") + .writeText( + buildString { + if (imports.isNotEmpty()) { + append(imports.joinToString("\n")) + append("\n\n") + } + append("plugins {\n") + append(plugins.joinToString("\n")) + append("\n}\n\n") + append("kotlin {\n") + append(kotlinTargets.joinToString("\n\n")) + append("\n\n") + append( + """ sourceSets { + commonMain.dependencies { + implementation(${conventions.composablesIconsLucideDependency}) + implementation(${conventions.composablesUriPainterDependency}) + implementation(${conventions.composablesUiDependency}) + } + } +""", + ) + append("}\n") + }, + ) + + val sourceFile = + File(moduleDir, "src/commonMain/kotlin/${packageName.replace(".", "/")}/Library.kt") + sourceFile.parentFile.mkdirs() + sourceFile.writeText( + """ + package $packageName + + import androidx.compose.foundation.layout.Box + import androidx.compose.foundation.layout.fillMaxSize + import androidx.compose.foundation.layout.padding + import androidx.compose.runtime.Composable + import androidx.compose.ui.Alignment + import androidx.compose.ui.Modifier + import androidx.compose.ui.unit.dp + import com.composables.ui.components.Text + import com.composables.ui.theme.ComposablesTheme + + @Composable + fun LibraryContent(modifier: Modifier = Modifier) { + ComposablesTheme { + Box( + modifier = modifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text("Hello from $moduleName") + } + } + } + """ + .trimIndent() + "\n", + ) + + return modulePath +} + private fun createModuleGroup( projectRoot: File, appRootDir: File, From 3964e51617e8006d6049e9693434059393e76448 Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:30:13 +0700 Subject: [PATCH 2/2] Document formatting commands in generated projects --- .../com/composables/cli/CliIntegrationTest.kt | 6 ++++++ cli/src/jvmMain/kotlin/Cli.kt | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt index d9cd877..9eac655 100644 --- a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt +++ b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt @@ -844,6 +844,12 @@ class CliIntegrationTest { assertThat(rootBuild).contains("alias(libs.plugins.spotless)") assertThat(rootBuild).contains("ktfmt()") + val readme = File(projectDir, "README.md").readText() + assertThat(readme).contains("## Code Formatting") + assertThat(readme).contains("This project uses ktfmt, provided via the Spotless gradle plugin.") + assertThat(readme).contains("./gradlew spotlessCheck") + assertThat(readme).contains("./gradlew spotlessApply") + val sharedApp = File(projectDir, "shared/src/commonMain/kotlin/com/example/sampleapp/App.kt").readText() assertThat(sharedApp) diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index a0bfdc8..234b99f 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -221,6 +221,23 @@ private fun buildProjectReadme( lines += "- ${instruction.label}: $description" } + lines += "" + lines += "## Code Formatting" + lines += "" + lines += "This project uses ktfmt, provided via the Spotless gradle plugin." + lines += "" + lines += "To check for any formatting issues run:" + lines += "" + lines += "```shell" + lines += "./gradlew spotlessCheck" + lines += "```" + lines += "" + lines += "To automatically format your code run:" + lines += "" + lines += "```shell" + lines += "./gradlew spotlessApply" + lines += "```" + return lines.joinToString("\n") + "\n" }