thunderbird / thunderbird/thunderbird-android
Add an architecture-aware CLI for scaffolding Gradle modules
Nobody has claimed this yet.
- Dominant language
- Kotlin
- Stars
- 14k
- Forks
- 2.8k
- Avg merge
- 3d 3h
- Merged PRs (30d)
- 57
Description
Summary
Creating a Gradle module currently requires manually creating its structure, selecting the correct build conventions, and registering it in multiple locations. This is repetitive and can result in inconsistent module structures, invalid names, or missed registrations.
Add a Kotlin/JVM CLI that scaffolds new modules and enforces the repository’s documented architecture and naming conventions. It should support interactive and non-interactive execution.
This is a follow-up to this PR review comment.
CLI implementation
The module generator must follow the project’s existing CLI conventions:
- Implement it as a Kotlin/JVM module under
:cli:module-generator-cli. - Apply
ThunderbirdPlugins.App.jvm. - Use Clikt through
libs.clikt. - Use
net.thunderbird.cli.module.generatoras its package. - Provide a
Main.ktentry point delegating to aCliktCommand. - Configure the main class as
net.thunderbird.cli.module.generator.MainKt. - Set
version = "unspecified". - Include the module in the root
buildCliToolstask. - Add a thin wrapper at
scripts/module-generatorthat builds the distribution and forwards all arguments to the CLI. - Keep all generation, validation, and file-update logic in Kotlin.
- Add a README under
cli/module-generator-cli/and list the wrapper inscripts/README.md.
Example:
./scripts/module-generator
CLI arguments
| Option | Clikt/Kotlin type | Value count | Required | Applies to | Description |
|---|---|---|---|---|---|
--type <type> |
ModuleType? using enum<ModuleType>() |
1 |
Required in non-interactive mode | All module types | Module type: feature, core, library, legacy, or cli. |
--name <name> |
String? |
1 |
Required in non-interactive mode | All module types | Colon-separated module area and optional subareas without the type prefix or generated suffixes. |
--internal-only |
Boolean using flag() |
0 |
No | feature, core |
Generate only the standard internal module for an existing API module. |
--internal-variant <variant>... |
List<String> using varargValues(min = 1) |
1..N when present |
No | feature, core |
Generate one or more qualified internal variants for an existing API module. |
--yes |
Boolean using flag() |
0 |
No | All module types | Skip the final confirmation. |
--allow-legacy |
Boolean using flag() |
0 |
Required for non-interactive legacy creation | legacy |
Acknowledge that the module is intended to isolate or migrate existing legacy code. |
--non-interactive |
Boolean using flag() |
0 |
No | All module types | Disable prompts and fail when required input is missing. |
--help |
Clikt eager option | 0 |
No | All module types | Display usage information and exit. |
Clikt supports an option with a variable number of whitespace-separated values through varargValues. By default, it requires at least one value and has no maximum.
The intended declaration is equivalent to:
private val internalVariants: List<String> by option(
"--internal-variant",
help = "Internal implementation variants to generate.",
).varargValues(min = 1)
This allows one variant:
--internal-variant eml
or multiple variants in the same option occurrence:
--internal-variant eml pdf markdown
Clikt’s multiple() handles repeating the entire option, such as --internal-variant eml --internal-variant pdf. That is not required for this CLI because varargValues(min = 1) directly represents the desired 1..N value list.
--internal-only and --internal-variant represent different layouts and must not be used together.
Interactive input
When required options are not provided, the CLI should ask for the module type:
featurecorelibrarylegacycli
It should then ask for the module name as a colon-separated path without its type prefix, for example:
newmodulemail:newfeaturemail:message:export
Feature and core layout
For feature and core, ask which layout to create:
apiandinternalinternalonly- One or more qualified
internalvariants only
Creating both api and internal should remain the default.
When qualified variants are selected interactively, the CLI should collect one or more variant names and apply the same validation as --internal-variant.
Examples:
| Type | Layout | Input | Generated Gradle projects |
|---|---|---|---|
| Feature | API and internal | newmodule |
:feature:newmodule:api, :feature:newmodule:internal |
| Feature | API and internal | mail:newfeature |
:feature:mail:newfeature:api, :feature:mail:newfeature:internal |
| Feature | Internal only | mail:newfeature |
:feature:mail:newfeature:internal |
| Feature | Internal variants | mail:message:export + eml |
:feature:mail:message:export:internal-eml |
| Feature | Internal variants | mail:message:export + eml, pdf |
:feature:mail:message:export:internal-eml, :feature:mail:message:export:internal-pdf |
| Core | API and internal | network:client |
:core:network:client:api, :core:network:client:internal |
| Core | Internal only | network:client |
:core:network:client:internal |
| Core | Internal variants | storage + sqlite, inmemory |
:core:storage:internal-sqlite, :core:storage:internal-inmemory |
| Library | Not applicable | newmodule |
:library:newmodule |
| Legacy | Not applicable | newmodule |
:legacy:newmodule |
| CLI | Not applicable | newtool |
:cli:newtool-cli |
When creating only internal modules:
- The corresponding
apimodule must already exist and be registered insettings.gradle.kts. - Every generated internal module must depend on the existing API module.
- The CLI must not modify or regenerate the API module.
- The operation must fail before writing files if the API module cannot be found.
- The operation must fail if any requested internal module already exists.
- Creating multiple variants must be atomic: either every requested variant is created or none are.
For CLI modules, add the -cli suffix automatically:
Gradle project: :cli:newtool-cli
Command name: newtool
Wrapper: scripts/newtool
Non-interactive execution
Options should take precedence over interactive input. If only some required values are provided, the CLI may prompt for missing values unless --non-interactive is used.
Create an API/internal pair:
./scripts/module-generator \
--type feature \
--name mail:newfeature \
--yes
Create only the standard internal module:
./scripts/module-generator \
--type feature \
--name mail:newfeature \
--internal-only \
--yes
Create one qualified internal variant:
./scripts/module-generator \
--type feature \
--name mail:message:export \
--internal-variant eml \
--yes
Create multiple qualified internal variants:
./scripts/module-generator \
--type feature \
--name mail:message:export \
--internal-variant eml pdf markdown \
--yes
This should atomically create:
:feature:mail:message:export:internal-eml
:feature:mail:message:export:internal-pdf
:feature:mail:message:export:internal-markdown
with package roots:
net.thunderbird.feature.mail.message.export.internal.eml
net.thunderbird.feature.mail.message.export.internal.pdf
net.thunderbird.feature.mail.message.export.internal.markdown
When --non-interactive is provided:
- Missing required options must produce a clear error.
- The CLI must not prompt.
- Validation failures must return a non-zero exit code.
- Legacy creation must require
--allow-legacy. - Interactive and non-interactive execution must produce identical files for the same inputs.
Internal-only options must be rejected for library, legacy, and cli.
Module naming conventions
The CLI must validate names using:
- Module Structure
- ADR-0008: Change shared module packages to
net.thunderbird - ADR-0009: Feature/Core API/Internal split and dependency rules
Enforce the following rules:
- The supplied name represents only the module area and optional subareas.
- The module type prefix is derived from
--type. - Module path segments must be lowercase.
- Multi-word Gradle module segments should use hyphens.
- Module names must start with a letter.
- Module names may contain only lowercase ASCII letters, digits, and hyphens.
- Colon-separated paths must not contain empty segments.
- CLI project names must end in
-cli; the generator adds this suffix automatically. - Generated package names must use valid lowercase Kotlin package identifiers.
- Hyphens in Gradle names must not appear in package names.
- Shared module packages must use
net.thunderbird. - New feature and core implementations must use
internal, neverimpl. - The supplied name must not contain generated suffixes such as
api,internal, or-cli. - Internal variant qualifiers must be lowercase and alphanumeric.
- Variant qualifiers must be unique within a single invocation.
- Reserved type names must not be accepted when they would create an ambiguous path.
Feature modules must use:
:feature:<area>[:<subarea>]:api
:feature:<area>[:<subarea>]:internal
:feature:<area>[:<subarea>]:internal-<variant>
Core modules must use:
:core:<area>[:<subarea>]:api
:core:<area>[:<subarea>]:internal
:core:<area>[:<subarea>]:internal-<variant>
Package names must follow the documented mapping:
| Gradle project | Package root |
|---|---|
:feature:account:settings:api |
net.thunderbird.feature.account.settings |
:feature:account:settings:internal |
net.thunderbird.feature.account.settings.internal |
:feature:mail:message:export:internal-eml |
net.thunderbird.feature.mail.message.export.internal.eml |
:core:network:api |
net.thunderbird.core.network |
:core:network:internal |
net.thunderbird.core.network.internal |
:core:storage:internal-sqlite |
net.thunderbird.core.storage.internal.sqlite |
:cli:resource-mover-cli |
net.thunderbird.cli.resource.mover |
API packages must not contain an .api segment.
Validate every input before creating or modifying files. Rejected values should produce an actionable error containing the violated convention and a valid example.
Module generation
The CLI should:
- Create module directories and
build.gradle.ktsfiles. - Use the repository’s convention plugins and current templates.
- Generate valid namespaces and package paths.
- Produce modules that build without additional configuration.
- Avoid adding speculative dependencies or application wiring.
For feature and core API/internal pairs:
- Generate both projects.
- Make
internaldepend on its siblingapi. - Keep API packages free of an
.apisegment. - Place implementation packages below
.internal.
For standard internal-only generation:
- Verify the sibling API project and directory exist.
- Verify the API project is registered in
settings.gradle.kts. - Generate only the standard
internalproject. - Add a dependency on the existing API project.
- Leave the API project unchanged.
For qualified internal variants:
- Accept a
List<String>containing one or more variants. - Generate one
internal-<variant>project per value. - Add a dependency from each variant to the existing API project.
- Map each module to
.internal.<variant>. - Validate all variants and targets before writing files.
- Reject duplicate variants rather than silently deduplicating them.
- Treat generation and registration as one atomic operation.
- Leave the API project unchanged.
For CLI modules:
- Apply
ThunderbirdPlugins.App.jvm. - Add
libs.clikt. - Generate a
CliktCommand. - Generate a
Main.ktentry point. - Configure
mainClass. - Use
net.thunderbird.cli.<name>. - Add the
-cliproject suffix. - Add a thin wrapper under
scripts/. - Add a module README.
- Ensure the module is included in
buildCliTools.
Gradle registration
The CLI should:
- Register every generated project in
settings.gradle.kts. - Add entries to the appropriate
include(...)group. - Preserve formatting and organization.
- Avoid duplicate registrations.
- Register multiple internal variants together.
- Update additional type-specific configuration when required.
For CLI modules, ensure the project is included in buildCliTools. If that task already discovers all :cli:* projects, verify the generated path matches that convention without modifying the root build unnecessarily.
Architecture enforcement
The generated structure must follow:
- Module Organization
- Module Structure
- Feature Modules
- ADR-0005: Central project configuration
- ADR-0007: Project structure
- ADR-0008: Change shared module packages to
net.thunderbird - ADR-0009: Feature/Core API/Internal split and dependency rules
Because legacy modules must not contain new development, selecting legacy should display a warning and require explicit confirmation that the module is intended to isolate or migrate existing legacy code.
The CLI should reject:
- Empty names or path segments.
- Uppercase characters.
- Underscores, whitespace, or unsupported characters.
- Segments beginning or ending with a hyphen.
- Consecutive hyphens.
- Malformed Gradle paths.
- Names that include a type prefix.
- Names containing generated
api,internal,-cli, or deprecatedimplsuffixes. - An empty
--internal-variant. - Invalid or duplicate internal variants.
- Internal-only generation when the API module does not exist.
- Internal-only options used with unsupported module types.
- Conflicting
--internal-onlyand--internal-variantoptions. - Modules, package directories, or wrappers that already exist.
- Inputs that violate the documented module hierarchy.
Validation failures must not leave partial files or registrations behind.
User experience
Before writing files, interactive execution should display:
- The selected module type.
- The selected feature/core layout.
- The normalized module name.
- All requested internal variants.
- The Gradle projects that will be created.
- The existing API project used for internal-only generation.
- Generated namespaces and package roots.
- Directories and build files that will be generated.
- Files that will be updated.
Ask the user to confirm unless --yes was provided.
Provide actionable errors and return a non-zero exit code when generation fails.
Documentation
Document:
- Interactive usage.
- Non-interactive usage.
- The CLI argument table.
- Clikt’s
varargValues(min = 1)behaviour. - Supported module types and layouts.
- API/internal pair generation.
- Standard internal-only generation.
- Single and multiple internal variant generation.
- Valid and invalid naming examples.
- Generated project and package mappings.
- Architecture restrictions.
- Failure and recovery behaviour.
The primary documentation should live at:
cli/module-generator-cli/README.md
The wrapper should also be listed in:
scripts/README.md
Testing
Add automated Kotlin tests covering:
- Every supported module type.
- Every feature/core layout.
- Interactive and non-interactive execution.
- API/internal pair generation.
- Standard internal-only generation.
--internal-variantwith one value.--internal-variantwith multiple whitespace-separated values.- An empty
--internal-variant. - Duplicate and invalid variants.
- Atomic failure when any requested variant cannot be created.
- Detection of an existing sibling API module.
- Failure when the sibling API is missing or unregistered.
- Variant name validation and package mapping.
- CLI module, command, and wrapper generation.
- Gradle module naming validation.
- Namespace and package derivation.
- Rejection of deprecated
implnaming. - Rejection of conflicting option combinations.
- Registration in
settings.gradle.kts. - CLI discovery by
buildCliTools. - Existing-module conflicts.
- Legacy acknowledgement requirements.
- Failure without partial modifications.
- Re-running without duplicate registrations.
Use fakes for filesystem access so generation and failure behaviour can be tested without modifying the real repository.
Acceptance criteria
- The generator is implemented as
:cli:module-generator-cli. - It is written in Kotlin and uses Clikt.
- It follows the existing JVM CLI module structure.
- A thin
scripts/module-generatorwrapper builds and invokes it. - The CLI supports interactive and fully non-interactive execution.
- Its options and Kotlin types are documented in a table.
- It supports
--type,--name,--internal-only,--internal-variant,--yes,--allow-legacy,--non-interactive, and--help. -
--internal-variantuses Clikt’svarargValues(min = 1). -
--internal-variantaccepts one or more whitespace-separated values. - Multiple variants are validated and generated atomically.
- Duplicate variants are rejected before modifying files.
- Feature and core generation defaults to an API/internal pair.
- It can generate only the standard internal module for an existing API.
- It can generate one or more qualified internal variants for an existing API.
- Internal-only generation verifies that the sibling API exists and is registered.
- Generated internal modules depend on their sibling API.
- Qualified variants use
internal-<variant>and.internal.<variant>. - Internal-only options are rejected for unsupported module types.
- It validates module names before modifying files.
- It enforces documented module, namespace, and package conventions.
- It uses
internalrather than the deprecatedimpl. - Generated projects are registered in
settings.gradle.kts. - Generated CLI modules use
-cliand are included inbuildCliTools. - Invalid input cannot produce partial changes.
- Legacy module creation requires explicit acknowledgement.
- Automated Kotlin tests cover generation, naming, validation, and registration.
- Usage documentation is included.
- Generated modules are recognized by Gradle and build successfully.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading docs/architecture/module-structure.md and the two linked ADRs, then inspect the root buildCliTools task and existing CLI conventions. The implementation belongs under cli/module-generator-cli with Main.kt, plus scripts/module-generator, cli/module-generator-cli/README.md, and scripts/README.md. Done means validated interactive and non-interactive scaffolding updates the required Gradle registrations and files atomically, with the specified options and errors.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kotlin
- Domain
- build-system, cli, tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100