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

714 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. |