714 lines
24 KiB
Markdown
714 lines
24 KiB
Markdown
# summer-bonfire — Module Design Plan
|
||
|
||
CLI command framework for SummerCMS. The Scala 3 equivalent of Laravel's `Illuminate\Console` + Artisan.
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
### Package & Naming
|
||
|
||
| Field | Value |
|
||
|-------|-------|
|
||
| Package | `summer.bonfire` |
|
||
| Organization | `com.golem15.summer` |
|
||
| Artifact | `summercms-bonfire` |
|
||
| Scala | 3.3.4 LTS |
|
||
| JDK | 21+ (Project Loom virtual threads) |
|
||
|
||
### Design Pattern
|
||
|
||
Follows the same **public trait + implementation class** pattern as compass and phrasebook:
|
||
|
||
```
|
||
Bonfire.scala — public API trait (Console)
|
||
BonfireRunner.scala — full implementation (CommandRunner)
|
||
```
|
||
|
||
All public API surface is defined via traits. Implementation classes are wired at the application layer — bonfire itself has no dependency on other summer modules.
|
||
|
||
---
|
||
|
||
## Dependencies
|
||
|
||
| Library | Version | Purpose |
|
||
|---------|---------|---------|
|
||
| case-app | 2.1.0-M29 | CLI argument parsing, auto-generated help text, case class → CLI mapping |
|
||
| fansi | 0.5.1 | ANSI colors/styles, visible-width-aware `fansi.Str` for table column sizing |
|
||
| JLine | 3.28.0 | Terminal raw mode (spinners), LineReader (prompts), terminal size detection |
|
||
| munit | 1.0.3 | Testing (test scope only) |
|
||
|
||
### Why these libraries
|
||
|
||
- **case-app** — VirtusLab maintains it (same team as scala-cli). Parses CLI args into case classes with auto-generated `--help`. Zero runtime reflection.
|
||
- **fansi** — Li Haoyi's library. Zero dependencies. Handles ANSI escape sequences correctly, including visible character width (critical for table column alignment). Used by Ammonite, Mill, etc.
|
||
- **JLine 3** — The standard Java terminal library. Provides raw mode (needed for spinner animation without newlines), LineReader (for interactive prompts with history/completion), and terminal capability detection (`isatty`, size, type).
|
||
- **No external TUI framework** — No Lanterna, no Textual, no bubbletea equivalent. All widgets (spinner, progress bar, table, prompts) are custom-built in Scala 3. This keeps the dependency tree minimal and gives full control over the UX.
|
||
|
||
---
|
||
|
||
## Module Structure
|
||
|
||
```
|
||
src/main/scala/summer/bonfire/
|
||
ExitCode.scala Enum: Success(0), Error(1), Invalid(2)
|
||
Verbosity.scala Enum: Quiet, Normal, Verbose, Debug
|
||
Symbols.scala Unicode constants (✓ ✗ → ● │ …)
|
||
Style.scala Centralized color definitions via fansi
|
||
TerminalDetector.scala TTY detection, NO_COLOR, TERM=dumb, terminal size
|
||
|
||
Input.scala Input trait — arguments, options, interactive flag
|
||
Output.scala Output trait — write, info, success, error, table, etc.
|
||
ConsoleInput.scala case-app backed Input implementation
|
||
ConsoleOutput.scala Full Output implementation with rich widgets
|
||
|
||
Command.scala Command trait — name, description, run()
|
||
CommandRunner.scala Register, discover, dispatch commands
|
||
CommandProvider.scala ServiceLoader SPI for plugin command registration
|
||
|
||
widgets/
|
||
Spinner.scala Braille dot animation via virtual thread
|
||
ProgressBar.scala 256-color gradient bar with block characters
|
||
Table.scala Box-drawing table with fansi.Str width-aware columns
|
||
Prompt.scala Interactive ask/confirm/choice/secret via JLine
|
||
|
||
generator/
|
||
GeneratorCommand.scala Base class for summer create:* scaffolding
|
||
Stub.scala Template stub loading and variable replacement
|
||
```
|
||
|
||
---
|
||
|
||
## Core Components
|
||
|
||
### ExitCode
|
||
|
||
```scala
|
||
enum ExitCode(val code: Int):
|
||
case Success extends ExitCode(0)
|
||
case Error extends ExitCode(1)
|
||
case Invalid extends ExitCode(2)
|
||
```
|
||
|
||
### Verbosity
|
||
|
||
```scala
|
||
enum Verbosity:
|
||
case Quiet, Normal, Verbose, Debug
|
||
```
|
||
|
||
Controls output filtering. `-q` = Quiet, default = Normal, `-v` = Verbose, `-vv` = Debug.
|
||
|
||
### Symbols
|
||
|
||
```scala
|
||
object Symbols:
|
||
val check = "✓"
|
||
val cross = "✗"
|
||
val arrow = "→"
|
||
val bullet = "●"
|
||
val pipe = "│"
|
||
val ellipsis = "…"
|
||
val warning = "⚠"
|
||
val info = "ℹ"
|
||
|
||
// Box drawing
|
||
val boxTopLeft = "┌"
|
||
val boxTopRight = "┐"
|
||
val boxBottomLeft = "└"
|
||
val boxBottomRight = "┘"
|
||
val boxHorizontal = "─"
|
||
val boxVertical = "│"
|
||
val boxTeeRight = "├"
|
||
val boxTeeLeft = "┤"
|
||
val boxCross = "┼"
|
||
val boxTeeDown = "┬"
|
||
val boxTeeUp = "┴"
|
||
```
|
||
|
||
### Style
|
||
|
||
```scala
|
||
object Style:
|
||
val success = fansi.Color.Green
|
||
val error = fansi.Color.Red
|
||
val warning = fansi.Color.Yellow
|
||
val info = fansi.Color.Cyan
|
||
val muted = fansi.Color.DarkGray
|
||
val bold = fansi.Bold.On
|
||
val dim = fansi.Color.DarkGray
|
||
|
||
def apply(style: fansi.Attrs, text: String): String =
|
||
if TerminalDetector.supportsColor then style(text).render
|
||
else text
|
||
```
|
||
|
||
### TerminalDetector
|
||
|
||
```scala
|
||
object TerminalDetector:
|
||
def isTTY: Boolean // System.console() != null + JLine Terminal check
|
||
def supportsColor: Boolean // !NO_COLOR && TERM != "dumb" && isTTY
|
||
def terminalWidth: Int // JLine Terminal.getWidth, fallback 80
|
||
def terminalHeight: Int // JLine Terminal.getHeight, fallback 24
|
||
def isCI: Boolean // CI=true or common CI env vars
|
||
```
|
||
|
||
Uses JLine's `TerminalBuilder` for accurate detection, with `System.console()` as fallback.
|
||
|
||
### Input trait
|
||
|
||
```scala
|
||
trait Input:
|
||
def argument(name: String): Option[String]
|
||
def arguments: Map[String, String]
|
||
def option(name: String): Option[String]
|
||
def options: Map[String, String]
|
||
def hasOption(name: String): Boolean
|
||
def isInteractive: Boolean
|
||
```
|
||
|
||
`ConsoleInput` wraps case-app's parsed result into this interface.
|
||
|
||
### Output trait
|
||
|
||
```scala
|
||
trait Output:
|
||
def write(message: String): Unit
|
||
def writeln(message: String): Unit
|
||
def newLine(count: Int = 1): Unit
|
||
|
||
// Styled output
|
||
def info(message: String): Unit // cyan prefix
|
||
def success(message: String): Unit // green ✓ prefix
|
||
def error(message: String): Unit // red ✗ prefix
|
||
def warning(message: String): Unit // yellow ⚠ prefix
|
||
def comment(message: String): Unit // muted/dim
|
||
|
||
// Verbosity-aware
|
||
def verbosity: Verbosity
|
||
def setVerbosity(v: Verbosity): Unit
|
||
def isQuiet: Boolean
|
||
def isVerbose: Boolean
|
||
def isDebug: Boolean
|
||
|
||
// Widgets (delegated to widget classes)
|
||
def table(headers: Seq[String], rows: Seq[Seq[String]]): Unit
|
||
def withSpinner[T](message: String)(block: => T): T
|
||
def withProgress[T](total: Int)(block: ProgressBar => T): T
|
||
|
||
// Interactive (delegated to Prompt)
|
||
def ask(question: String, default: String = ""): String
|
||
def confirm(question: String, default: Boolean = false): Boolean
|
||
def choice(question: String, choices: Seq[String], default: Int = 0): String
|
||
def secret(question: String): String
|
||
```
|
||
|
||
### Command trait
|
||
|
||
```scala
|
||
trait Command:
|
||
def name: String
|
||
def description: String
|
||
def run(input: Input, output: Output): ExitCode
|
||
```
|
||
|
||
Commands are simple: a name, a description, and a `run` method that receives Input/Output and returns an ExitCode. No inheritance trees, no lifecycle hooks. case-app handles argument parsing before the command runs.
|
||
|
||
### CommandRunner
|
||
|
||
```scala
|
||
trait CommandRunner:
|
||
def register(command: Command): Unit
|
||
def registerAll(commands: Seq[Command]): Unit
|
||
def get(name: String): Option[Command]
|
||
def all: Map[String, Command]
|
||
def dispatch(args: Array[String]): ExitCode
|
||
def discoverProviders(): Unit // ServiceLoader discovery
|
||
```
|
||
|
||
`dispatch` flow:
|
||
1. Parse first arg as command name
|
||
2. Look up in registry
|
||
3. Parse remaining args via case-app into Input
|
||
4. Call `command.run(input, output)`
|
||
5. Return ExitCode
|
||
|
||
`discoverProviders` uses `java.util.ServiceLoader[CommandProvider]` to find all providers on the classpath and registers their commands.
|
||
|
||
### CommandProvider (SPI)
|
||
|
||
```scala
|
||
trait CommandProvider:
|
||
def commands: Seq[Command]
|
||
```
|
||
|
||
Plugins implement this trait and register via `META-INF/services/summer.bonfire.CommandProvider`. At boot, `CommandRunner.discoverProviders()` loads all implementations and registers their commands.
|
||
|
||
Example plugin registration:
|
||
|
||
```
|
||
# META-INF/services/summer.bonfire.CommandProvider
|
||
com.golem15.blog.BlogCommandProvider
|
||
```
|
||
|
||
---
|
||
|
||
## Custom-Built Console Widgets
|
||
|
||
All widgets are built from scratch in Scala 3. No external TUI framework. Inspired by [SSU](https://git.golem15.com/golem15/ssu)'s Go terminal UX (charmbracelet/bubbletea ecosystem).
|
||
|
||
### Spinner
|
||
|
||
Braille dot animation: `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`
|
||
|
||
```scala
|
||
class Spinner(message: String, output: Output):
|
||
private val frames = Array('⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏')
|
||
|
||
def run[T](block: => T): T =
|
||
if !TerminalDetector.isTTY then
|
||
output.writeln(s"[...] $message")
|
||
return block
|
||
|
||
val done = new java.util.concurrent.atomic.AtomicBoolean(false)
|
||
val animator = Thread.ofVirtual().start { () =>
|
||
var i = 0
|
||
while !done.get() do
|
||
val frame = Style(Style.info, frames(i % frames.length).toString)
|
||
print(s"\r$frame $message")
|
||
System.out.flush()
|
||
Thread.sleep(80)
|
||
i += 1
|
||
}
|
||
|
||
try
|
||
val result = block
|
||
done.set(true)
|
||
animator.join()
|
||
println(s"\r${Style(Style.success, Symbols.check)} $message")
|
||
result
|
||
catch
|
||
case e: Throwable =>
|
||
done.set(true)
|
||
animator.join()
|
||
println(s"\r${Style(Style.error, Symbols.cross)} $message")
|
||
throw e
|
||
```
|
||
|
||
Key design decisions:
|
||
- **Virtual thread** for animation — JDK 21 Loom, zero overhead, no executor/thread pool needed
|
||
- **80ms frame delay** — smooth animation without CPU waste
|
||
- **`\r` carriage return** — overwrites same line, no scrolling
|
||
- **Non-TTY fallback** — prints static `[...] message` line
|
||
|
||
### ProgressBar
|
||
|
||
256-color gradient bar using Unicode block characters: `█▉▊▋▌▍▎▏`
|
||
|
||
```scala
|
||
class ProgressBar(total: Int, output: Output):
|
||
private val blocks = Array('█', '▉', '▊', '▋', '▌', '▍', '▎', '▏')
|
||
private var current = 0
|
||
|
||
// 256-color gradient from cyan (37) to green (34)
|
||
private def gradientColor(ratio: Double): fansi.Attr = ...
|
||
|
||
def advance(step: Int = 1): Unit =
|
||
current = math.min(current + step, total)
|
||
if TerminalDetector.isTTY then renderBar()
|
||
else renderPlain()
|
||
|
||
private def renderBar(): Unit =
|
||
val width = math.min(TerminalDetector.terminalWidth - 20, 50)
|
||
val ratio = current.toDouble / total
|
||
val filled = (ratio * width).toInt
|
||
val remainder = ((ratio * width - filled) * blocks.length).toInt
|
||
val bar = "█" * filled +
|
||
(if remainder > 0 then blocks(blocks.length - remainder).toString else "") +
|
||
" " * (width - filled - (if remainder > 0 then 1 else 0))
|
||
val pct = f"${ratio * 100}%5.1f%%"
|
||
val counter = s"$current/$total"
|
||
print(s"\r ${gradientColor(ratio)(bar).render} $pct $counter")
|
||
System.out.flush()
|
||
if current >= total then println()
|
||
|
||
private def renderPlain(): Unit =
|
||
val pct = (current.toDouble / total * 100).toInt
|
||
if pct % 10 == 0 then
|
||
output.writeln(s"[$current/$total] $pct%")
|
||
```
|
||
|
||
Key design decisions:
|
||
- **256-color ANSI gradient** — color shifts from cyan to green as progress increases
|
||
- **Sub-character precision** — Unicode block chars give 8 sub-positions per character cell
|
||
- **Width-aware** — reads terminal width, caps at 50 chars
|
||
- **Non-TTY fallback** — prints `[N/M] percentage%` every 10%
|
||
|
||
### Table
|
||
|
||
Box-drawing table with fansi.Str width-aware column sizing:
|
||
|
||
```scala
|
||
class Table(headers: Seq[String], rows: Seq[Seq[String]], output: Output):
|
||
def render(): Unit =
|
||
val allRows = headers +: rows
|
||
val colWidths = calculateColumnWidths(allRows)
|
||
|
||
printBorder(colWidths, BorderStyle.Top)
|
||
printRow(headers, colWidths, isHeader = true)
|
||
printBorder(colWidths, BorderStyle.Middle)
|
||
rows.foreach(row => printRow(row, colWidths))
|
||
printBorder(colWidths, BorderStyle.Bottom)
|
||
|
||
private def calculateColumnWidths(rows: Seq[Seq[String]]): Seq[Int] =
|
||
// Uses fansi.Str(cell).length for visible width (ignores ANSI escape codes)
|
||
// Respects terminal width, truncates columns if needed
|
||
...
|
||
```
|
||
|
||
Uses `fansi.Str.length` for visible character width — correctly handles ANSI-styled strings that contain invisible escape sequences. Box-drawing characters from `Symbols` object.
|
||
|
||
Example output:
|
||
|
||
```
|
||
┌──────────────┬─────────┬────────┐
|
||
│ Plugin │ Version │ Status │
|
||
├──────────────┼─────────┼────────┤
|
||
│ golem15.blog │ 1.2.0 │ active │
|
||
│ golem15.pages│ 1.0.3 │ active │
|
||
└──────────────┴─────────┴────────┘
|
||
```
|
||
|
||
Non-TTY fallback: tab-separated plain text.
|
||
|
||
### Prompt
|
||
|
||
Interactive prompts via JLine LineReader:
|
||
|
||
```scala
|
||
class Prompt(output: Output):
|
||
private lazy val terminal = TerminalBuilder.builder().build()
|
||
private lazy val reader = LineReaderBuilder.builder().terminal(terminal).build()
|
||
|
||
def ask(question: String, default: String = ""): String =
|
||
if !TerminalDetector.isTTY then return default
|
||
val prompt = s"${Style(Style.info, "?")} $question"
|
||
val suffix = if default.nonEmpty then s" ${Style.muted(s"($default)")}" else ""
|
||
val result = reader.readLine(s"$prompt$suffix ${Symbols.arrow} ")
|
||
if result.trim.isEmpty then default else result.trim
|
||
|
||
def confirm(question: String, default: Boolean = false): Boolean =
|
||
val hint = if default then "Y/n" else "y/N"
|
||
val answer = ask(s"$question [$hint]")
|
||
if answer.isEmpty then default
|
||
else answer.toLowerCase.startsWith("y")
|
||
|
||
def choice(question: String, choices: Seq[String], default: Int = 0): String =
|
||
// Renders numbered list, reads selection
|
||
// In raw mode: arrow key navigation with highlight
|
||
...
|
||
|
||
def secret(question: String): String =
|
||
if !TerminalDetector.isTTY then return ""
|
||
reader.readLine(s"${Style(Style.info, "?")} $question ${Symbols.arrow} ", '*')
|
||
|
||
def multiSelect(question: String, choices: Seq[String]): Seq[String] =
|
||
// JLine raw mode: arrow keys to move, space to toggle, enter to confirm
|
||
// Renders checkboxes: [✓] Selected [ ] Unselected
|
||
...
|
||
```
|
||
|
||
Key design decisions:
|
||
- **JLine LineReader** — handles readline editing, history, platform-specific terminal quirks
|
||
- **Raw mode for interactive selectors** — arrow key navigation, space to toggle, enter to confirm
|
||
- **Secret input** — masks with `*` characters via JLine's masking support
|
||
- **Non-TTY fallback** — returns defaults or reads plain stdin lines
|
||
|
||
---
|
||
|
||
## SSU Feature Mapping
|
||
|
||
How SSU's Go terminal UX maps to summer-bonfire's Scala 3 implementation:
|
||
|
||
| SSU (Go) | summer-bonfire (Scala 3) | Notes |
|
||
|----------|--------------------------|-------|
|
||
| charmbracelet/bubbles spinner | Custom `Spinner` + virtual thread animation | Braille frames, 80ms interval, `\r` overwrite |
|
||
| charmbracelet/bubbles progress | Custom `ProgressBar` + fansi 256-color gradient | Block chars for sub-cell precision |
|
||
| fatih/color | fansi library + `Style` object | fansi handles visible width correctly |
|
||
| charmbracelet/lipgloss table | Custom `Table` + box-drawing chars | fansi.Str for width-aware columns |
|
||
| bubbletea interactive selector | Custom `Prompt.choice/multiSelect` via JLine raw mode | Arrow keys, space toggle, enter confirm |
|
||
| mattn/go-isatty | `TerminalDetector` via JLine + System.console() | Also checks NO_COLOR, TERM=dumb, CI |
|
||
| Unicode symbol constants | `Symbols` object | ✓ ✗ → ● ⚠ ℹ + box-drawing set |
|
||
| Non-TTY fallback (plain text) | All widgets degrade gracefully when `!isTTY` | CI-friendly output |
|
||
|
||
---
|
||
|
||
## GeneratorCommand (Scaffolding)
|
||
|
||
Base class for `summer create:*` commands:
|
||
|
||
```scala
|
||
abstract class GeneratorCommand extends Command:
|
||
def stubDirectory: String // where .stub files live
|
||
def targetDirectory: String // where generated files go
|
||
|
||
protected def generateFile(
|
||
stubName: String,
|
||
targetPath: String,
|
||
replacements: Map[String, String],
|
||
): Unit =
|
||
val stub = loadStub(stubName)
|
||
val content = replacements.foldLeft(stub) { (text, kv) =>
|
||
text.replace(s"{{${kv._1}}}", kv._2)
|
||
}
|
||
java.nio.file.Files.writeString(
|
||
java.nio.file.Path.of(targetPath),
|
||
content,
|
||
)
|
||
```
|
||
|
||
Stub files use `{{variable}}` placeholders. Commands like `summer create:plugin`, `summer create:model`, etc. extend `GeneratorCommand` and define their stubs + replacements.
|
||
|
||
Stubs live in a well-known directory (configurable via summer-compass), not embedded as resources — this lets users customize scaffolding templates.
|
||
|
||
---
|
||
|
||
## case-app Integration
|
||
|
||
case-app maps case class fields to CLI options:
|
||
|
||
```scala
|
||
import caseapp.*
|
||
|
||
case class CreatePluginOptions(
|
||
@Name("n") name: String,
|
||
@Name("d") description: Option[String] = None,
|
||
@Name("f") force: Boolean = false,
|
||
) derives Parser, Help
|
||
```
|
||
|
||
The `CommandRunner.dispatch` flow:
|
||
1. First positional arg = command name (e.g., `create:plugin`)
|
||
2. Remaining args passed to case-app for typed parsing
|
||
3. Parsed result wrapped into `ConsoleInput`
|
||
4. `command.run(input, output)` called
|
||
|
||
For the top-level `summer` binary, case-app's `CommandsEntryPoint` maps subcommand names to their option parsers.
|
||
|
||
---
|
||
|
||
## ServiceLoader SPI
|
||
|
||
Plugin commands are discovered via `java.util.ServiceLoader`:
|
||
|
||
```
|
||
META-INF/services/summer.bonfire.CommandProvider
|
||
```
|
||
|
||
Each plugin's JAR contains this file listing its `CommandProvider` implementation class. At boot:
|
||
|
||
```scala
|
||
val providers = ServiceLoader.load(classOf[CommandProvider])
|
||
providers.forEach(p => runner.registerAll(p.commands))
|
||
```
|
||
|
||
This is the same pattern used by JDBC drivers, SLF4J backends, etc. No classpath scanning, no reflection beyond what ServiceLoader does.
|
||
|
||
---
|
||
|
||
## Non-TTY Graceful Degradation
|
||
|
||
Every widget checks `TerminalDetector.isTTY` and degrades:
|
||
|
||
| Widget | TTY mode | Non-TTY fallback |
|
||
|--------|----------|------------------|
|
||
| Spinner | Braille animation on single line | `[...] message` static line |
|
||
| ProgressBar | 256-color gradient bar | `[N/M] percentage%` every 10% |
|
||
| Table | Box-drawing Unicode table | Tab-separated plain text |
|
||
| Prompt.ask | Styled prompt with readline | Read from stdin, use default |
|
||
| Prompt.confirm | Y/n prompt | Use default value |
|
||
| Prompt.choice | Arrow-key selector | Numbered list, read number |
|
||
| Prompt.secret | Masked input (`*`) | Read from stdin (unmasked) |
|
||
| Colors | Full ANSI color/style | Plain text (NO_COLOR respected) |
|
||
|
||
`TerminalDetector.supportsColor` checks:
|
||
- `NO_COLOR` env var (respects [no-color.org](https://no-color.org))
|
||
- `TERM=dumb`
|
||
- `!isTTY`
|
||
- `FORCE_COLOR` env var (override to enable)
|
||
|
||
---
|
||
|
||
## Notes for Lucas (Central Infrastructure)
|
||
|
||
### Multi-module sbt integration
|
||
|
||
When the top-level `build.sbt` becomes a multi-project build:
|
||
|
||
```sbt
|
||
lazy val bonfire = (project in file("modules/summer-bonfire"))
|
||
.settings(
|
||
name := "summercms-bonfire",
|
||
// ... deps
|
||
)
|
||
```
|
||
|
||
Bonfire has no inter-module dependencies — it's standalone. Other modules depend on it (e.g., `summer-party` for plugin command registration), not the other way around.
|
||
|
||
### Version centralization
|
||
|
||
New dependencies that need central version management:
|
||
|
||
```scala
|
||
val CaseAppVersion = "2.1.0-M29"
|
||
val FansiVersion = "0.5.1"
|
||
val JLineVersion = "3.28.0"
|
||
```
|
||
|
||
### Publishing
|
||
|
||
Publish under `com.golem15.summer`:
|
||
|
||
```
|
||
com.golem15.summer:summercms-bonfire_3:0.1.0-SNAPSHOT
|
||
```
|
||
|
||
### CI testing
|
||
|
||
Some tests need special handling:
|
||
- **Spinner/ProgressBar tests** — verify output strings without actual TTY (mock TerminalDetector)
|
||
- **Prompt tests** — pipe stdin, verify prompts written to output
|
||
- **Integration tests** — may need `script` or pseudo-TTY for full interactive testing (mark with `@Tag`)
|
||
- **ServiceLoader tests** — test JARs with META-INF/services entries
|
||
|
||
### ServiceLoader registration
|
||
|
||
Each plugin JAR that provides commands must include:
|
||
|
||
```
|
||
src/main/resources/META-INF/services/summer.bonfire.CommandProvider
|
||
```
|
||
|
||
This is a build-time concern. Document in plugin scaffolding template.
|
||
|
||
### scala-cli sync
|
||
|
||
`project.scala` must stay in sync with `build.sbt` dependencies:
|
||
|
||
```
|
||
project.scala build.sbt
|
||
//> using dep ...case-app... ↔ "com.github.alexarchambault" %% "case-app" % ...
|
||
//> using dep ...fansi... ↔ "com.lihaoyi" %% "fansi" % ...
|
||
//> using dep ...jline... ↔ "org.jline" % "jline" % ...
|
||
```
|
||
|
||
Consider a CI check or script that verifies both files declare the same deps.
|
||
|
||
---
|
||
|
||
## Implementation Phases
|
||
|
||
### Phase 1 — Core Types & Output
|
||
|
||
Files: `ExitCode.scala`, `Verbosity.scala`, `Symbols.scala`, `Style.scala`, `TerminalDetector.scala`, `Output.scala`, `ConsoleOutput.scala` (basic write/info/success/error only)
|
||
|
||
Goal: Basic styled console output works. Can print colored messages, detect terminal capabilities.
|
||
|
||
Tests: Style rendering, TerminalDetector mocking, Output formatting.
|
||
|
||
### Phase 2 — Command Framework
|
||
|
||
Files: `Input.scala`, `ConsoleInput.scala`, `Command.scala`, `CommandRunner.scala`, `CommandProvider.scala`
|
||
|
||
Goal: Commands can be registered, discovered via ServiceLoader, dispatched. case-app parses arguments into Input.
|
||
|
||
Tests: Command registration, dispatch, argument parsing, ServiceLoader discovery with test providers.
|
||
|
||
### Phase 3 — Rich Output Widgets
|
||
|
||
Files: `widgets/Table.scala`, `widgets/Spinner.scala`, `widgets/ProgressBar.scala`
|
||
|
||
Goal: Table rendering with box-drawing and width-aware columns. Spinner animation via virtual threads. Progress bar with gradient colors.
|
||
|
||
Tests: Table output verification, spinner non-TTY fallback, progress bar percentage output.
|
||
|
||
### Phase 4 — Interactive Prompts
|
||
|
||
Files: `widgets/Prompt.scala`
|
||
|
||
Goal: ask, confirm, choice, secret prompts via JLine. Raw mode for interactive selector.
|
||
|
||
Tests: Piped stdin for ask/confirm, choice rendering, secret masking.
|
||
|
||
### Phase 5 — Generator / Scaffolding
|
||
|
||
Files: `generator/GeneratorCommand.scala`, `generator/Stub.scala`
|
||
|
||
Goal: Base class for `summer create:*` commands. Stub loading, variable replacement, file generation.
|
||
|
||
Tests: Stub rendering, file creation, variable replacement.
|
||
|
||
### Phase 6 — Polish
|
||
|
||
Features: Shell completion generation (bash/zsh/fish), `Prompt.multiSelect`, comprehensive non-TTY degradation tests, error handling edge cases.
|
||
|
||
Tests: Full non-TTY test suite, shell completion output, multiselect interaction.
|
||
|
||
---
|
||
|
||
## Example: Full Command Lifecycle
|
||
|
||
```scala
|
||
// 1. Plugin defines a command
|
||
object MigrateCommand extends Command:
|
||
val name = "migrate"
|
||
val description = "Run database migrations"
|
||
|
||
def run(input: Input, output: Output): ExitCode =
|
||
val pending = MigrationService.pending()
|
||
if pending.isEmpty then
|
||
output.info("Nothing to migrate.")
|
||
return ExitCode.Success
|
||
|
||
output.table(
|
||
headers = Seq("Migration", "Batch"),
|
||
rows = pending.map(m => Seq(m.name, m.batch.toString))
|
||
)
|
||
|
||
if !output.confirm(s"Run ${pending.size} migrations?") then
|
||
return ExitCode.Success
|
||
|
||
output.withProgress(pending.size) { bar =>
|
||
pending.foreach { migration =>
|
||
MigrationService.run(migration)
|
||
bar.advance()
|
||
}
|
||
}
|
||
|
||
output.success(s"Ran ${pending.size} migrations.")
|
||
ExitCode.Success
|
||
|
||
// 2. Plugin registers via CommandProvider
|
||
class LagoonCommandProvider extends CommandProvider:
|
||
def commands = Seq(MigrateCommand, RollbackCommand, SeedCommand)
|
||
|
||
// 3. META-INF/services/summer.bonfire.CommandProvider
|
||
// com.golem15.summer.lagoon.LagoonCommandProvider
|
||
|
||
// 4. User runs: summer migrate
|
||
// CommandRunner discovers LagoonCommandProvider, registers MigrateCommand, dispatches
|
||
```
|
||
|
||
---
|
||
|
||
## Rejected Alternatives
|
||
|
||
| Alternative | Why rejected |
|
||
|-------------|-------------|
|
||
| **Decline (Scala TUI)** | Full terminal UI framework — too heavy for a CLI command runner. We only need a few widgets, not a full TUI app framework. |
|
||
| **Scopt** | Less ergonomic than case-app. case-app's case class derivation is cleaner and VirtusLab maintains it. |
|
||
| **Picocli** | Java library, annotation-based. Doesn't leverage Scala 3 features (enums, derives, extension methods). |
|
||
| **OS-Lib for terminal** | Li Haoyi's library is great for process execution but doesn't provide raw terminal mode or readline. JLine is purpose-built for this. |
|
||
| **Cats Effect / ZIO for concurrency** | Virtual threads replace async runtimes for our use case. A spinner animation thread is trivial with Loom — no need for an effect system. |
|