Tnital commit

This commit is contained in:
Jakub Zych
2026-02-24 00:43:22 +01:00
commit 0b9f54df40
6 changed files with 883 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# sbt build artifacts
target/
project/target/
project/project/
# IDE
.idea/
.bsp/
.metals/
.vscode/
*.iml
# OS
.DS_Store
Thumbs.db

713
PLAN.md Normal file
View 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. |

122
README.md Normal file
View File

@@ -0,0 +1,122 @@
# summer-bonfire
CLI command framework module for [SummerCMS](https://git.golem15.com/golem15/summercms). Provides command registration, dispatch, and rich terminal output — the Scala 3 equivalent of Laravel's `Illuminate\Console`.
Bonfire is **not** a CLI argument parser. [case-app](https://github.com/alexarchambault/case-app) handles argument parsing and help generation. Bonfire is the CMS command framework on top: how commands are registered, discovered via plugins, dispatched, and how they produce rich terminal output (spinners, progress bars, tables, interactive prompts).
## Quick start
```scala
import summer.bonfire.*
// Define a command
object GreetCommand extends Command:
val name = "greet"
val description = "Greet a user"
def run(input: Input, output: Output): ExitCode =
val name = input.argument("name").getOrElse("World")
output.success(s"Hello, $name!")
ExitCode.Success
// Register and run
val runner = CommandRunner()
runner.register(GreetCommand)
runner.dispatch(args)
```
## Rich terminal output
All widgets are custom-built in Scala 3 — no external TUI framework. Inspired by [SSU](https://git.golem15.com/golem15/ssu)'s terminal UX.
```scala
// Spinner with braille animation (virtual thread powered)
output.withSpinner("Compiling plugin...") {
compilePlugin()
}
// Progress bar with 256-color gradient
output.withProgress(files.size) { bar =>
files.foreach { file =>
processFile(file)
bar.advance()
}
}
// Box-drawing table
output.table(
headers = Seq("Plugin", "Version", "Status"),
rows = Seq(
Seq("golem15.blog", "1.2.0", "active"),
Seq("golem15.pages", "1.0.3", "active"),
)
)
// Interactive prompts (via JLine)
val name = output.ask("Plugin name?")
val confirm = output.confirm("Create plugin?")
val choice = output.choice("Select database:", Seq("postgres", "mysql", "sqlite"))
```
## Plugin command discovery
Plugins register commands via `CommandProvider` (ServiceLoader SPI):
```scala
class BlogCommandProvider extends CommandProvider:
def commands: Seq[Command] = Seq(
BlogSeedCommand,
BlogImportCommand,
)
```
Commands are auto-discovered at boot from all plugins on the classpath.
## Non-TTY graceful degradation
All widgets detect terminal capabilities and degrade gracefully:
- Spinners become `[...] message` static lines
- Progress bars become `[N/M] percentage%` text updates
- Colors stripped when `NO_COLOR` is set or `TERM=dumb`
- Interactive prompts fall back to line-by-line stdin
## Dependencies
| Dependency | Version | Purpose |
|-----------|---------|---------|
| `com.github.alexarchambault:case-app` | 2.1.0-M29 | CLI argument parsing, auto-generated help |
| `com.lihaoyi:fansi` | 0.5.1 | ANSI colors/styles, visible-width-aware strings |
| `org.jline:jline` | 3.28.0 | Terminal raw mode, readline, key events, terminal detection |
| `org.scalameta:munit` | 1.0.3 | Testing (test scope only) |
No effect systems. No Cats, no ZIO. Pure direct-style Scala 3 on JDK 21.
## Building
```bash
# sbt
sbt compile
sbt test
# scala-cli
scala-cli compile .
scala-cli test .
```
Requires JDK 21+ and sbt 1.10+.
## Roadmap
See [PLAN.md](PLAN.md) for the full module architecture and implementation phases.
- **Phase 1** — Core types and output abstractions
- **Phase 2** — Command framework with case-app and ServiceLoader SPI
- **Phase 3** — Rich output widgets (table, spinner, progress bar)
- **Phase 4** — Interactive prompts via JLine
- **Phase 5** — GeneratorCommand base for `summer create:*` scaffolding
- **Phase 6** — Polish: shell completion, multiselect, non-TTY tests
## License
Part of SummerCMS.

22
build.sbt Normal file
View File

@@ -0,0 +1,22 @@
lazy val root = (project in file("."))
.settings(
name := "summercms-bonfire",
organization := "com.golem15.summer",
version := "0.1.0-SNAPSHOT",
scalaVersion := "3.3.4",
libraryDependencies ++= Seq(
"com.github.alexarchambault" %% "case-app" % "2.1.0-M29",
"com.lihaoyi" %% "fansi" % "0.5.1",
"org.jline" % "jline" % "3.28.0",
"org.scalameta" %% "munit" % "1.0.3" % Test,
),
scalacOptions ++= Seq(
"-Wunused:all",
"-deprecation",
"-feature",
),
testFrameworks += new TestFramework("munit.Framework"),
)

10
project.scala Normal file
View File

@@ -0,0 +1,10 @@
//> using scala 3.3.4
//> using jvm 21
//> using dep com.github.alexarchambault::case-app:2.1.0-M29
//> using dep com.lihaoyi::fansi:0.5.1
//> using dep org.jline:jline:3.28.0
//> using test.dep org.scalameta::munit::1.0.3
//> using option -Wunused:all -deprecation -feature
//> using publish.organization com.golem15.summer
//> using publish.name summercms-bonfire
//> using publish.version 0.1.0-SNAPSHOT

1
project/build.properties Normal file
View File

@@ -0,0 +1 @@
sbt.version=1.10.7