Tnital commit
This commit is contained in:
713
PLAN.md
Normal file
713
PLAN.md
Normal file
@@ -0,0 +1,713 @@
|
||||
# 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. |
|
||||
Reference in New Issue
Block a user