# 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. |