24 KiB
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
enum ExitCode(val code: Int):
case Success extends ExitCode(0)
case Error extends ExitCode(1)
case Invalid extends ExitCode(2)
Verbosity
enum Verbosity:
case Quiet, Normal, Verbose, Debug
Controls output filtering. -q = Quiet, default = Normal, -v = Verbose, -vv = Debug.
Symbols
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
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
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
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
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
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
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:
- Parse first arg as command name
- Look up in registry
- Parse remaining args via case-app into Input
- Call
command.run(input, output) - Return ExitCode
discoverProviders uses java.util.ServiceLoader[CommandProvider] to find all providers on the classpath and registers their commands.
CommandProvider (SPI)
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's Go terminal UX (charmbracelet/bubbletea ecosystem).
Spinner
Braille dot animation: ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
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
\rcarriage return — overwrites same line, no scrolling- Non-TTY fallback — prints static
[...] messageline
ProgressBar
256-color gradient bar using Unicode block characters: █▉▊▋▌▍▎▏
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:
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:
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:
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:
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:
- First positional arg = command name (e.g.,
create:plugin) - Remaining args passed to case-app for typed parsing
- Parsed result wrapped into
ConsoleInput 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:
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_COLORenv var (respects no-color.org)TERM=dumb!isTTYFORCE_COLORenv var (override to enable)
Notes for Lucas (Central Infrastructure)
Multi-module sbt integration
When the top-level build.sbt becomes a multi-project build:
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:
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
scriptor 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
// 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. |