From 0b9f54df40f442b84e0ccc84973529ba4264d509 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 24 Feb 2026 00:43:22 +0100 Subject: [PATCH] Tnital commit --- .gitignore | 15 + PLAN.md | 713 +++++++++++++++++++++++++++++++++++++++ README.md | 122 +++++++ build.sbt | 22 ++ project.scala | 10 + project/build.properties | 1 + 6 files changed, 883 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 build.sbt create mode 100644 project.scala create mode 100644 project/build.properties diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3a996d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# sbt build artifacts +target/ +project/target/ +project/project/ + +# IDE +.idea/ +.bsp/ +.metals/ +.vscode/ +*.iml + +# OS +.DS_Store +Thumbs.db diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..1034d12 --- /dev/null +++ b/PLAN.md @@ -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. | diff --git a/README.md b/README.md new file mode 100644 index 0000000..736b542 --- /dev/null +++ b/README.md @@ -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. diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..e44c7e1 --- /dev/null +++ b/build.sbt @@ -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"), + ) diff --git a/project.scala b/project.scala new file mode 100644 index 0000000..f180bbf --- /dev/null +++ b/project.scala @@ -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 diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..73df629 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.7