Files
summer-bonfire/PLAN.md
2026-02-24 00:43:22 +01:00

24 KiB
Raw Permalink Blame History

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:

  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)

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
  • \r carriage return — overwrites same line, no scrolling
  • Non-TTY fallback — prints static [...] message line

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:

  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:

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)
  • 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:

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

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