docs(05): research CLI scaffolding domain
Phase 5: CLI Scaffolding - Standard stack: ZIO CLI 0.7.4, os-lib 0.11.7, fansi 0.5.1 - Architecture patterns for noun-verb commands documented - Template rendering with string interpolation - File operations with os-lib - Interactive wizard mode via ZIO CLI built-in - Common pitfalls catalogued
This commit is contained in:
975
.planning/phases/05-cli-scaffolding/05-RESEARCH.md
Normal file
975
.planning/phases/05-cli-scaffolding/05-RESEARCH.md
Normal file
@@ -0,0 +1,975 @@
|
||||
# Phase 5: CLI Scaffolding - Research
|
||||
|
||||
**Researched:** 2026-02-05
|
||||
**Domain:** CLI parsing, file generation, interactive prompts, terminal output
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
This phase implements the `summer` CLI tool for scaffolding plugins, themes, components, controllers, models, and migrations. The research covers CLI parsing libraries for Scala 3/ZIO, file system operations, template-based code generation, interactive prompts, and terminal UI (colors, progress bars).
|
||||
|
||||
The recommended stack uses: **ZIO CLI 0.7.4** for command parsing (native ZIO integration, built-in wizard mode, shell completion), **os-lib 0.11.7** for file operations (bundled with Mill, simple API, path handling), **fansi 0.5.1** for colored terminal output, and **string interpolation** for template generation (simple scaffolds don't need template engine). Interactive prompts use ZIO CLI's built-in wizard mode with fallback to JLine for advanced cases.
|
||||
|
||||
**Primary recommendation:** Use ZIO CLI for all command parsing with noun-verb subcommand structure, os-lib for file generation with `os.makeDir.all` and `os.write`, string interpolation for templates with placeholder replacement, and fansi for colored output with `--no-color` fallback.
|
||||
|
||||
## Standard Stack
|
||||
|
||||
The established libraries/tools for this domain:
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| ZIO CLI | 0.7.4 | Command parsing | Native ZIO, wizard mode, shell completion, type-safe |
|
||||
| os-lib | 0.11.7 | File operations | Bundled with Mill, simple API, cross-platform paths |
|
||||
| fansi | 0.5.1 | Colored output | Zero-dependency, Scala 3 native, efficient |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| JLine | 4.0.0 | Advanced prompts | Complex interactive input beyond wizard mode |
|
||||
| circe-yaml | 0.16.1 | YAML generation | Generating plugin.yaml manifests |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| ZIO CLI | MainArgs | MainArgs simpler but no wizard mode, shell completion |
|
||||
| ZIO CLI | Decline | Decline uses cats, needs interop-cats for ZIO |
|
||||
| os-lib | java.nio.file | nio verbose, os-lib has better ergonomics |
|
||||
| fansi | scala-rainbow | scala-rainbow less maintained, fansi more features |
|
||||
| String interpolation | Pebble | Overkill for scaffolds, adds dependency |
|
||||
|
||||
**Installation (Mill build.mill):**
|
||||
```scala
|
||||
def mvnDeps = Seq(
|
||||
// Existing deps from Phase 1-4...
|
||||
|
||||
// CLI parsing
|
||||
mvn"dev.zio::zio-cli:0.7.4",
|
||||
|
||||
// File operations (bundled with Mill, but explicit for CLI module)
|
||||
mvn"com.lihaoyi::os-lib:0.11.7",
|
||||
|
||||
// Colored terminal output
|
||||
mvn"com.lihaoyi::fansi:0.5.1"
|
||||
)
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended CLI Module Structure
|
||||
```
|
||||
cli/
|
||||
├── src/
|
||||
│ ├── Main.scala # CLI entry point, CliApp
|
||||
│ ├── commands/
|
||||
│ │ ├── PluginCommands.scala # summer plugin create/list
|
||||
│ │ ├── ThemeCommands.scala # summer theme create/list
|
||||
│ │ ├── ComponentCommands.scala # summer component create
|
||||
│ │ ├── ControllerCommands.scala # summer controller create
|
||||
│ │ ├── ModelCommands.scala # summer model create
|
||||
│ │ ├── MigrationCommands.scala # summer migration create/run/status
|
||||
│ │ └── VersionCommand.scala # summer version
|
||||
│ ├── scaffold/
|
||||
│ │ ├── ScaffoldService.scala # File generation service
|
||||
│ │ ├── TemplateRenderer.scala # Variable substitution
|
||||
│ │ └── templates/
|
||||
│ │ ├── plugin/
|
||||
│ │ ├── theme/
|
||||
│ │ ├── component/
|
||||
│ │ ├── controller/
|
||||
│ │ └── model/
|
||||
│ ├── config/
|
||||
│ │ └── ConfigDetector.scala # Find project root, load config
|
||||
│ └── output/
|
||||
│ ├── Console.scala # Colored output service
|
||||
│ └── Progress.scala # Progress bar service
|
||||
```
|
||||
|
||||
### Pattern 1: ZIO CLI Command Structure (Noun-Verb)
|
||||
**What:** Commands follow noun-verb pattern with subcommands
|
||||
**When to use:** All CLI commands
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: ZIO CLI documentation + CONTEXT.md decisions
|
||||
import zio.cli.*
|
||||
|
||||
// Command models
|
||||
sealed trait CliCommand
|
||||
object CliCommand:
|
||||
case class PluginCreate(name: String, dryRun: Boolean) extends CliCommand
|
||||
case class ThemeCreate(name: String, template: String, dryRun: Boolean) extends CliCommand
|
||||
case class ComponentCreate(plugin: String, name: String, dryRun: Boolean) extends CliCommand
|
||||
case class ModelCreate(plugin: String, name: String, noMigration: Boolean, dryRun: Boolean) extends CliCommand
|
||||
case class MigrationCreate(plugin: String, name: Option[String], dryRun: Boolean) extends CliCommand
|
||||
case class Version() extends CliCommand
|
||||
|
||||
// Options
|
||||
val dryRunOption: Options[Boolean] =
|
||||
Options.boolean("dry-run").alias("n")
|
||||
.withHelp("Show what would be created without creating")
|
||||
|
||||
val templateOption: Options[String] =
|
||||
Options.text("template").withDefault("starter")
|
||||
.withHelp("Theme template: blank or starter")
|
||||
|
||||
// Arguments
|
||||
val pluginNameArg: Args[String] =
|
||||
Args.text("name")
|
||||
.withHelp("Plugin name in Author.Name format (e.g., Golem15.Blog)")
|
||||
|
||||
// Subcommands
|
||||
val pluginCreate: Command[CliCommand.PluginCreate] =
|
||||
Command("create", dryRunOption, pluginNameArg)
|
||||
.withHelp("Create a new plugin scaffold")
|
||||
.map((dryRun, name) => CliCommand.PluginCreate(name, dryRun))
|
||||
|
||||
val pluginCommand: Command[CliCommand] =
|
||||
Command("plugin")
|
||||
.withHelp("Plugin management commands")
|
||||
.subcommands(pluginCreate)
|
||||
|
||||
// Top-level command combining all subcommands
|
||||
val summerCommand: Command[CliCommand] =
|
||||
Command("summer")
|
||||
.subcommands(
|
||||
pluginCommand,
|
||||
themeCommand,
|
||||
componentCommand,
|
||||
controllerCommand,
|
||||
modelCommand,
|
||||
migrationCommand,
|
||||
versionCommand
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern 2: CLI Application Entry Point
|
||||
**What:** Main entry using ZIOCliDefault with CliApp.make
|
||||
**When to use:** CLI main class
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: ZIO CLI documentation
|
||||
import zio.*
|
||||
import zio.cli.*
|
||||
|
||||
object Main extends ZIOCliDefault:
|
||||
val cliApp = CliApp.make(
|
||||
name = "summer",
|
||||
version = BuildInfo.version,
|
||||
summary = HelpDoc.p("SummerCMS scaffolding CLI"),
|
||||
command = summerCommand
|
||||
) { command =>
|
||||
command match
|
||||
case CliCommand.PluginCreate(name, dryRun) =>
|
||||
PluginScaffold.create(name, dryRun)
|
||||
case CliCommand.ThemeCreate(name, template, dryRun) =>
|
||||
ThemeScaffold.create(name, template, dryRun)
|
||||
case CliCommand.Version() =>
|
||||
printVersion
|
||||
// ... other commands
|
||||
}.provideSome[ZIOAppArgs](
|
||||
ScaffoldService.live,
|
||||
ConsoleOutput.live,
|
||||
ConfigDetector.live
|
||||
)
|
||||
|
||||
def printVersion: ZIO[Any, Nothing, Unit] =
|
||||
Console.printLine(s"SummerCMS ${BuildInfo.version} (Scala ${BuildInfo.scalaVersion}, JVM ${System.getProperty("java.version")})")
|
||||
.orDie
|
||||
```
|
||||
|
||||
### Pattern 3: Scaffold Service with os-lib
|
||||
**What:** Service for file generation using os-lib operations
|
||||
**When to use:** All file creation operations
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: os-lib documentation
|
||||
import zio.*
|
||||
|
||||
trait ScaffoldService:
|
||||
def createDirectory(path: os.Path): IO[ScaffoldError, Unit]
|
||||
def writeFile(path: os.Path, content: String): IO[ScaffoldError, Unit]
|
||||
def exists(path: os.Path): UIO[Boolean]
|
||||
def listFiles(path: os.Path): IO[ScaffoldError, List[os.Path]]
|
||||
|
||||
object ScaffoldService:
|
||||
val live: ZLayer[Any, Nothing, ScaffoldService] =
|
||||
ZLayer.succeed {
|
||||
new ScaffoldService:
|
||||
def createDirectory(path: os.Path): IO[ScaffoldError, Unit] =
|
||||
ZIO.attemptBlocking {
|
||||
os.makeDir.all(path)
|
||||
}.mapError(e => ScaffoldError.FileSystemError(path.toString, e))
|
||||
|
||||
def writeFile(path: os.Path, content: String): IO[ScaffoldError, Unit] =
|
||||
ZIO.attemptBlocking {
|
||||
// Create parent directories if needed
|
||||
os.makeDir.all(path / os.up)
|
||||
os.write(path, content)
|
||||
}.mapError(e => ScaffoldError.FileSystemError(path.toString, e))
|
||||
|
||||
def exists(path: os.Path): UIO[Boolean] =
|
||||
ZIO.succeed(os.exists(path))
|
||||
|
||||
def listFiles(path: os.Path): IO[ScaffoldError, List[os.Path]] =
|
||||
ZIO.attemptBlocking {
|
||||
os.list(path).toList
|
||||
}.mapError(e => ScaffoldError.FileSystemError(path.toString, e))
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Template Rendering with String Interpolation
|
||||
**What:** Simple variable substitution in template strings
|
||||
**When to use:** Scaffold file generation
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: WinterCMS BaseScaffoldCommand pattern adapted to Scala
|
||||
case class TemplateVars(
|
||||
name: String,
|
||||
author: String,
|
||||
plugin: String
|
||||
):
|
||||
// Derived variables
|
||||
val lowerName: String = name.toLowerCase
|
||||
val lowerAuthor: String = author.toLowerCase
|
||||
val lowerPlugin: String = plugin.toLowerCase
|
||||
val studlyName: String = name.split("[_\\-\\s]").map(_.capitalize).mkString
|
||||
val studlyAuthor: String = author.capitalize
|
||||
val studlyPlugin: String = plugin.capitalize
|
||||
val snakeName: String = name.replaceAll("([A-Z])", "_$1").toLowerCase.stripPrefix("_")
|
||||
val snakePluralName: String = pluralize(snakeName)
|
||||
val titleName: String = name.split("[_\\-\\s]").map(_.capitalize).mkString(" ")
|
||||
val pluginId: String = s"$lowerAuthor.$lowerPlugin"
|
||||
val pluginCode: String = s"$studlyAuthor.$studlyPlugin"
|
||||
val pluginNamespace: String = s"$studlyAuthor.$studlyPlugin"
|
||||
val tableName: String = s"${lowerAuthor}_${lowerPlugin}_${snakePluralName}"
|
||||
val timestamp: String = java.time.LocalDateTime.now()
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
|
||||
|
||||
trait TemplateRenderer:
|
||||
def render(template: String, vars: TemplateVars): String
|
||||
|
||||
object TemplateRenderer:
|
||||
val live: ZLayer[Any, Nothing, TemplateRenderer] =
|
||||
ZLayer.succeed {
|
||||
new TemplateRenderer:
|
||||
def render(template: String, vars: TemplateVars): String =
|
||||
template
|
||||
.replace("{{name}}", vars.name)
|
||||
.replace("{{lower_name}}", vars.lowerName)
|
||||
.replace("{{studly_name}}", vars.studlyName)
|
||||
.replace("{{snake_name}}", vars.snakeName)
|
||||
.replace("{{author}}", vars.author)
|
||||
.replace("{{lower_author}}", vars.lowerAuthor)
|
||||
.replace("{{studly_author}}", vars.studlyAuthor)
|
||||
.replace("{{plugin}}", vars.plugin)
|
||||
.replace("{{lower_plugin}}", vars.lowerPlugin)
|
||||
.replace("{{studly_plugin}}", vars.studlyPlugin)
|
||||
.replace("{{plugin_id}}", vars.pluginId)
|
||||
.replace("{{plugin_namespace}}", vars.pluginNamespace)
|
||||
.replace("{{table_name}}", vars.tableName)
|
||||
.replace("{{timestamp}}", vars.timestamp)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Plugin Scaffold Generation
|
||||
**What:** Complete plugin scaffold with proper structure
|
||||
**When to use:** `summer plugin create Author.Name`
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: WinterCMS CreatePlugin pattern + CONTEXT.md decisions
|
||||
object PluginScaffold:
|
||||
def create(nameArg: String, dryRun: Boolean): ZIO[ScaffoldService & ConsoleOutput, ScaffoldError, Unit] =
|
||||
for
|
||||
// 1. Parse Author.Name format
|
||||
(author, name) <- ZIO.fromEither(parsePluginName(nameArg))
|
||||
.mapError(ScaffoldError.InvalidName(_))
|
||||
|
||||
// 2. Build paths
|
||||
pluginsDir = os.pwd / "plugins"
|
||||
pluginDir = pluginsDir / author.toLowerCase / name.toLowerCase
|
||||
|
||||
// 3. Check if exists
|
||||
scaffold <- ZIO.service[ScaffoldService]
|
||||
console <- ZIO.service[ConsoleOutput]
|
||||
exists <- scaffold.exists(pluginDir)
|
||||
_ <- ZIO.when(exists) {
|
||||
ZIO.fail(ScaffoldError.AlreadyExists(pluginDir.toString))
|
||||
}
|
||||
|
||||
// 4. Build template vars
|
||||
vars = TemplateVars(name, author, name)
|
||||
|
||||
// 5. Define files to create
|
||||
files = List(
|
||||
(pluginDir / "Plugin.scala", pluginTemplate),
|
||||
(pluginDir / "plugin.yaml", manifestTemplate),
|
||||
(pluginDir / "routes.scala", routesTemplate),
|
||||
(pluginDir / "models" / ".gitkeep", ""),
|
||||
(pluginDir / "controllers" / ".gitkeep", ""),
|
||||
(pluginDir / "components" / ".gitkeep", ""),
|
||||
(pluginDir / "resources" / "db" / "migration" / ".gitkeep", ""),
|
||||
(pluginDir / "resources" / "lang" / "en" / "lang.yaml", langTemplate),
|
||||
(pluginDir / "resources" / "views" / ".gitkeep", "")
|
||||
)
|
||||
|
||||
// 6. Render and create (or show dry-run)
|
||||
renderer = TemplateRenderer.live
|
||||
_ <- ZIO.foreach(files) { case (path, template) =>
|
||||
val content = renderer.render(template, vars)
|
||||
if dryRun then
|
||||
console.info(s"Would create: $path")
|
||||
else
|
||||
scaffold.writeFile(path, content) *>
|
||||
console.success(s"Created: $path")
|
||||
}
|
||||
|
||||
// 7. Show next steps
|
||||
_ <- console.nextSteps(List(
|
||||
s"Edit Plugin.scala to configure your plugin",
|
||||
s"Add models with: summer model create ${vars.pluginCode} ModelName",
|
||||
s"Add components with: summer component create ${vars.pluginCode} ComponentName",
|
||||
s"Run migrations with: summer migration run"
|
||||
))
|
||||
yield ()
|
||||
|
||||
private def parsePluginName(name: String): Either[String, (String, String)] =
|
||||
name.split("\\.").toList match
|
||||
case List(author, plugin) if author.nonEmpty && plugin.nonEmpty =>
|
||||
Right((author, plugin))
|
||||
case _ =>
|
||||
Left(s"Invalid plugin name '$name'. Use Author.Name format (e.g., Golem15.Blog)")
|
||||
|
||||
private val pluginTemplate = """
|
||||
|package {{plugin_namespace}}
|
||||
|
|
||||
|import com.summercms.plugin.*
|
||||
|import zio.*
|
||||
|
|
||||
|// TODO: Configure your plugin here
|
||||
|class Plugin extends SummerPlugin:
|
||||
| def id: PluginId = PluginId("{{lower_author}}", "{{lower_plugin}}")
|
||||
|
|
||||
| def register(ctx: PluginContext): PluginRegistration =
|
||||
| PluginRegistration(
|
||||
| components = List.empty,
|
||||
| permissions = List.empty,
|
||||
| navigation = List.empty,
|
||||
| settings = List.empty,
|
||||
| events = List.empty,
|
||||
| extensions = List.empty
|
||||
| )
|
||||
|
|
||||
| def boot: ZIO[PluginEnv, PluginError, Unit] =
|
||||
| ZIO.unit
|
||||
|
|
||||
| def shutdown: ZIO[Any, Nothing, Unit] =
|
||||
| ZIO.unit
|
||||
|""".stripMargin
|
||||
|
||||
private val manifestTemplate = """
|
||||
|vendor: {{lower_author}}
|
||||
|name: {{lower_plugin}}
|
||||
|version: 1.0.0
|
||||
|description: "TODO: Add description"
|
||||
|author: {{studly_author}}
|
||||
|
|
||||
|dependencies: {}
|
||||
|
|
||||
|enabled: true
|
||||
|""".stripMargin
|
||||
```
|
||||
|
||||
### Pattern 6: Colored Console Output with fansi
|
||||
**What:** Terminal output service with colors and styling
|
||||
**When to use:** All CLI output
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: fansi documentation
|
||||
import fansi.*
|
||||
|
||||
trait ConsoleOutput:
|
||||
def info(message: String): UIO[Unit]
|
||||
def success(message: String): UIO[Unit]
|
||||
def warning(message: String): UIO[Unit]
|
||||
def error(message: String): UIO[Unit]
|
||||
def nextSteps(steps: List[String]): UIO[Unit]
|
||||
|
||||
object ConsoleOutput:
|
||||
def live(useColors: Boolean = true): ZLayer[Any, Nothing, ConsoleOutput] =
|
||||
ZLayer.succeed {
|
||||
new ConsoleOutput:
|
||||
private def colorize(str: Str): String =
|
||||
if useColors then str.render else str.plainText
|
||||
|
||||
def info(message: String): UIO[Unit] =
|
||||
Console.printLine(colorize(Color.Cyan(message))).orDie
|
||||
|
||||
def success(message: String): UIO[Unit] =
|
||||
Console.printLine(colorize(Color.Green("[OK] ") ++ Str(message))).orDie
|
||||
|
||||
def warning(message: String): UIO[Unit] =
|
||||
Console.printLine(colorize(Color.Yellow("[WARN] ") ++ Str(message))).orDie
|
||||
|
||||
def error(message: String): UIO[Unit] =
|
||||
Console.printLine(colorize(Color.Red("[ERROR] ") ++ Str(message))).orDie
|
||||
|
||||
def nextSteps(steps: List[String]): UIO[Unit] =
|
||||
val header = colorize(Bold.On("Next steps:"))
|
||||
val bulletPoints = steps.map(s => s" - $s").mkString("\n")
|
||||
Console.printLine(s"\n$header\n$bulletPoints\n").orDie
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 7: Migration Timestamp Naming
|
||||
**What:** Migrations named with timestamp prefix for ordering
|
||||
**When to use:** `summer migration create` command
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: CONTEXT.md decisions
|
||||
object MigrationScaffold:
|
||||
def generateMigrationName(description: String): String =
|
||||
val timestamp = java.time.LocalDateTime.now()
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
|
||||
val snakeName = description
|
||||
.replaceAll("([A-Z])", "_$1")
|
||||
.toLowerCase
|
||||
.stripPrefix("_")
|
||||
.replaceAll("\\s+", "_")
|
||||
s"${timestamp}_$snakeName"
|
||||
|
||||
// e.g., "Create Posts Table" -> "20260205_143022_create_posts_table.scala"
|
||||
|
||||
def create(plugin: String, nameOpt: Option[String], dryRun: Boolean): ZIO[ScaffoldEnv, ScaffoldError, Unit] =
|
||||
for
|
||||
(author, pluginName) <- ZIO.fromEither(parsePluginName(plugin))
|
||||
name = nameOpt.getOrElse("migration")
|
||||
migrationName = generateMigrationName(name)
|
||||
migrationDir = os.pwd / "plugins" / author.toLowerCase / pluginName.toLowerCase / "resources" / "db" / "migration"
|
||||
|
||||
vars = TemplateVars(name, author, pluginName)
|
||||
content = migrationTemplate.render(vars)
|
||||
|
||||
scaffold <- ZIO.service[ScaffoldService]
|
||||
console <- ZIO.service[ConsoleOutput]
|
||||
|
||||
_ <- if dryRun then
|
||||
console.info(s"Would create: $migrationDir/${migrationName}.scala")
|
||||
else
|
||||
scaffold.writeFile(migrationDir / s"${migrationName}.scala", content) *>
|
||||
console.success(s"Created: $migrationDir/${migrationName}.scala")
|
||||
yield ()
|
||||
```
|
||||
|
||||
### Pattern 8: Interactive Wizard Mode
|
||||
**What:** ZIO CLI's built-in wizard prompts for missing arguments
|
||||
**When to use:** Interactive mode when args missing or `--wizard` flag
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: ZIO CLI built-in commands documentation
|
||||
// Wizard mode is automatic in ZIO CLI with --wizard flag
|
||||
|
||||
// If user runs: summer plugin create --wizard
|
||||
// ZIO CLI will prompt:
|
||||
// > Select command: [plugin, theme, component, ...]
|
||||
// > Enter plugin name:
|
||||
// > Enable dry-run? [y/N]
|
||||
|
||||
// For custom prompts beyond wizard (e.g., validation loops):
|
||||
object InteractivePrompt:
|
||||
def promptWithValidation(
|
||||
prompt: String,
|
||||
validate: String => Either[String, String]
|
||||
): ZIO[Any, Nothing, String] =
|
||||
def loop: ZIO[Any, Nothing, String] =
|
||||
Console.print(prompt).orDie *>
|
||||
Console.readLine.orDie.flatMap { input =>
|
||||
validate(input) match
|
||||
case Right(valid) => ZIO.succeed(valid)
|
||||
case Left(error) =>
|
||||
Console.printLine(fansi.Color.Red(error).render).orDie *>
|
||||
loop
|
||||
}
|
||||
loop
|
||||
|
||||
// Usage for plugin name validation:
|
||||
def promptPluginName: ZIO[Any, Nothing, String] =
|
||||
promptWithValidation(
|
||||
"Plugin name (Author.Name): ",
|
||||
input => parsePluginName(input).map(_._1 + "." + _._2)
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern 9: Config File Detection
|
||||
**What:** Find SummerCMS project root by looking for marker files
|
||||
**When to use:** CLI startup to determine paths
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: Common CLI pattern (CONTEXT.md Claude's discretion)
|
||||
trait ConfigDetector:
|
||||
def findProjectRoot: IO[ScaffoldError, os.Path]
|
||||
def loadConfig: IO[ScaffoldError, ProjectConfig]
|
||||
|
||||
object ConfigDetector:
|
||||
// Marker files that indicate SummerCMS root
|
||||
private val markers = List(
|
||||
"build.mill",
|
||||
"build.mill.yaml",
|
||||
".summercms"
|
||||
)
|
||||
|
||||
val live: ZLayer[Any, Nothing, ConfigDetector] =
|
||||
ZLayer.succeed {
|
||||
new ConfigDetector:
|
||||
def findProjectRoot: IO[ScaffoldError, os.Path] =
|
||||
ZIO.attemptBlocking {
|
||||
var current = os.pwd
|
||||
while (current != os.root) {
|
||||
if (markers.exists(m => os.exists(current / m)))
|
||||
return ZIO.succeed(current)
|
||||
current = current / os.up
|
||||
}
|
||||
throw new Exception("Not in a SummerCMS project")
|
||||
}.flatMap(identity)
|
||||
.mapError(e => ScaffoldError.NotInProject(e.getMessage))
|
||||
|
||||
def loadConfig: IO[ScaffoldError, ProjectConfig] =
|
||||
for
|
||||
root <- findProjectRoot
|
||||
configPath = root / ".summercms" / "config.yaml"
|
||||
exists <- ZIO.succeed(os.exists(configPath))
|
||||
config <- if exists then
|
||||
ZIO.attemptBlocking(os.read(configPath))
|
||||
.flatMap(parseConfig)
|
||||
.mapError(e => ScaffoldError.ConfigError(e.getMessage))
|
||||
else
|
||||
ZIO.succeed(ProjectConfig.default)
|
||||
yield config
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 10: Progress Bar for Larger Operations
|
||||
**What:** Simple progress indication for multi-file scaffolds
|
||||
**When to use:** Theme scaffold with Tailwind, any operation with multiple steps
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: Common CLI pattern (CONTEXT.md Claude's discretion)
|
||||
trait Progress:
|
||||
def start(total: Int, description: String): UIO[ProgressHandle]
|
||||
def update(handle: ProgressHandle, current: Int): UIO[Unit]
|
||||
def complete(handle: ProgressHandle): UIO[Unit]
|
||||
|
||||
case class ProgressHandle(id: Long, total: Int, description: String)
|
||||
|
||||
object Progress:
|
||||
val live: ZLayer[Any, Nothing, Progress] =
|
||||
ZLayer.succeed {
|
||||
new Progress:
|
||||
private val counter = new java.util.concurrent.atomic.AtomicLong(0)
|
||||
|
||||
def start(total: Int, description: String): UIO[ProgressHandle] =
|
||||
ZIO.succeed {
|
||||
val handle = ProgressHandle(counter.incrementAndGet(), total, description)
|
||||
print(s"$description [${progressBar(0, total)}] 0/$total\r")
|
||||
handle
|
||||
}
|
||||
|
||||
def update(handle: ProgressHandle, current: Int): UIO[Unit] =
|
||||
ZIO.succeed {
|
||||
print(s"${handle.description} [${progressBar(current, handle.total)}] $current/${handle.total}\r")
|
||||
}
|
||||
|
||||
def complete(handle: ProgressHandle): UIO[Unit] =
|
||||
ZIO.succeed {
|
||||
println(s"${handle.description} [${progressBar(handle.total, handle.total)}] ${handle.total}/${handle.total}")
|
||||
}
|
||||
|
||||
private def progressBar(current: Int, total: Int): String =
|
||||
val width = 30
|
||||
val filled = (current.toDouble / total * width).toInt
|
||||
val empty = width - filled
|
||||
"=" * filled + " " * empty
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Direct System.out.println:** Use ConsoleOutput service for testability and color support
|
||||
- **Blocking file I/O without ZIO:** Wrap all os-lib calls in `ZIO.attemptBlocking`
|
||||
- **Hard-coded paths:** Use ConfigDetector to find project root
|
||||
- **Mutable state in scaffolds:** Use ZIO Ref if state needed between steps
|
||||
- **Ignoring `--dry-run`:** Every file operation must check dry-run flag
|
||||
- **Silent failures:** Always report errors with clear messages and suggestions
|
||||
- **Requiring interactive input in CI:** Check `--no-interaction` flag
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
Problems that look simple but have existing solutions:
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Command parsing | Regex on args | ZIO CLI | Type safety, help gen, completion, wizard |
|
||||
| File system ops | java.io.File | os-lib | Cross-platform, ergonomic, path safety |
|
||||
| Terminal colors | ANSI escape codes | fansi | Handles terminal detection, efficient |
|
||||
| Progress bars | Custom spinners | Simple print loop | Sufficient for scaffolding use case |
|
||||
| YAML generation | String concat | circe-yaml | Proper escaping, structure |
|
||||
| Template rendering | Custom parser | String interpolation | Scaffolds are simple, no need for Pebble |
|
||||
| Input validation | Manual loops | ZIO CLI validators | Composable, type-safe |
|
||||
|
||||
**Key insight:** CLI scaffolding is IO-heavy but logic-light. Use proven libraries for parsing and file operations; keep template generation simple with string interpolation.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Path Separator Issues on Windows
|
||||
**What goes wrong:** Paths broken on Windows due to hardcoded `/`
|
||||
**Why it happens:** Using string concatenation instead of path combinators
|
||||
**How to avoid:** Always use `os.Path` with `/` operator: `root / "plugins" / author`
|
||||
**Warning signs:** "File not found" errors only on Windows
|
||||
|
||||
### Pitfall 2: Existing File Overwrite
|
||||
**What goes wrong:** User's existing code overwritten silently
|
||||
**Why it happens:** No existence check before write
|
||||
**How to avoid:** Check `os.exists()` before write, fail with clear message (no `--force` per CONTEXT.md)
|
||||
**Warning signs:** Lost user code, angry developers
|
||||
|
||||
### Pitfall 3: Invalid Plugin Name Format
|
||||
**What goes wrong:** Directory structure wrong, plugin won't load
|
||||
**Why it happens:** Name not validated (missing dot, special characters)
|
||||
**How to avoid:** Validate format upfront: `^[A-Z][a-zA-Z0-9]*\\.[A-Z][a-zA-Z0-9]*$`
|
||||
**Warning signs:** Plugin discovery failures, namespace errors
|
||||
|
||||
### Pitfall 4: Missing Parent Directories
|
||||
**What goes wrong:** File write fails with "directory not found"
|
||||
**Why it happens:** Trying to write file before creating parent dirs
|
||||
**How to avoid:** Use `os.makeDir.all(path / os.up)` before `os.write`
|
||||
**Warning signs:** "No such file or directory" errors
|
||||
|
||||
### Pitfall 5: CI Environment Interactive Prompts
|
||||
**What goes wrong:** Build hangs waiting for input
|
||||
**Why it happens:** Interactive prompt in non-interactive environment
|
||||
**How to avoid:** Check `--no-interaction` flag, fail with clear error on missing required args
|
||||
**Warning signs:** CI timeouts, hanging builds
|
||||
|
||||
### Pitfall 6: Wizard Mode Not Triggering
|
||||
**What goes wrong:** User expects prompts but gets error
|
||||
**Why it happens:** ZIO CLI wizard requires explicit `--wizard` flag
|
||||
**How to avoid:** Document wizard mode clearly, consider making it default for missing args
|
||||
**Warning signs:** Confused users, support requests
|
||||
|
||||
### Pitfall 7: Color Output in Non-TTY
|
||||
**What goes wrong:** ANSI codes visible as garbage characters
|
||||
**Why it happens:** Colors output to redirected stdout or file
|
||||
**How to avoid:** fansi handles this, also support `--no-color` flag
|
||||
**Warning signs:** Garbled output in logs, CI output
|
||||
|
||||
### Pitfall 8: Template Variable Collision
|
||||
**What goes wrong:** Template contains literal `{{name}}` that shouldn't be replaced
|
||||
**Why it happens:** Content and variables use same syntax
|
||||
**How to avoid:** Use distinctive delimiters or escape sequence for literal braces
|
||||
**Warning signs:** Unexpected content replacement
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from official sources:
|
||||
|
||||
### Complete CLI Main Entry
|
||||
```scala
|
||||
// Source: ZIO CLI documentation
|
||||
package com.summercms.cli
|
||||
|
||||
import zio.*
|
||||
import zio.cli.*
|
||||
|
||||
object Main extends ZIOCliDefault:
|
||||
// Global options
|
||||
val noColorOption: Options[Boolean] =
|
||||
Options.boolean("no-color")
|
||||
.withHelp("Disable colored output")
|
||||
|
||||
val verboseOption: Options[Boolean] =
|
||||
Options.boolean("verbose").alias("v")
|
||||
.withHelp("Enable verbose output")
|
||||
|
||||
val quietOption: Options[Boolean] =
|
||||
Options.boolean("quiet").alias("q")
|
||||
.withHelp("Suppress non-essential output")
|
||||
|
||||
val noInteractionOption: Options[Boolean] =
|
||||
Options.boolean("no-interaction")
|
||||
.withHelp("Disable interactive prompts (for CI)")
|
||||
|
||||
// Build complete command tree
|
||||
val command: Command[CliCommand] =
|
||||
Command("summer", noColorOption ++ verboseOption ++ quietOption ++ noInteractionOption)
|
||||
.subcommands(
|
||||
PluginCommands.command,
|
||||
ThemeCommands.command,
|
||||
ComponentCommands.command,
|
||||
ControllerCommands.command,
|
||||
ModelCommands.command,
|
||||
MigrationCommands.command,
|
||||
VersionCommand.command
|
||||
)
|
||||
|
||||
val cliApp: CliApp[CliCommand] = CliApp.make(
|
||||
name = "summer",
|
||||
version = BuildInfo.version,
|
||||
summary = HelpDoc.p("SummerCMS scaffolding and development CLI"),
|
||||
command = command
|
||||
) { case ((noColor, verbose, quiet, noInteraction), cmd) =>
|
||||
val consoleLayer = ConsoleOutput.live(!noColor)
|
||||
val configLayer = ConfigDetector.live
|
||||
val scaffoldLayer = ScaffoldService.live
|
||||
|
||||
cmd match
|
||||
case CliCommand.PluginCreate(name, dryRun) =>
|
||||
PluginScaffold.create(name, dryRun)
|
||||
.provide(consoleLayer, configLayer, scaffoldLayer)
|
||||
|
||||
case CliCommand.Version() =>
|
||||
Console.printLine(
|
||||
s"SummerCMS ${BuildInfo.version} (Scala ${BuildInfo.scalaVersion}, JVM ${System.getProperty("java.version")})"
|
||||
)
|
||||
|
||||
// ... other commands
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Scaffold with Template Choice
|
||||
```scala
|
||||
// Source: WinterCMS CreateTheme pattern + CONTEXT.md decisions
|
||||
object ThemeScaffold:
|
||||
sealed trait ThemeTemplate
|
||||
case object Blank extends ThemeTemplate
|
||||
case object Starter extends ThemeTemplate
|
||||
|
||||
def create(
|
||||
name: String,
|
||||
templateArg: String,
|
||||
dryRun: Boolean
|
||||
): ZIO[ScaffoldEnv, ScaffoldError, Unit] =
|
||||
for
|
||||
// 1. Parse template choice
|
||||
template <- ZIO.fromEither(
|
||||
templateArg.toLowerCase match
|
||||
case "blank" => Right(Blank)
|
||||
case "starter" => Right(Starter)
|
||||
case other => Left(ScaffoldError.InvalidTemplate(other))
|
||||
)
|
||||
|
||||
// 2. Parse theme name (Author.ThemeName format)
|
||||
(author, themeName) <- ZIO.fromEither(parseThemeName(name))
|
||||
|
||||
// 3. Build paths
|
||||
config <- ZIO.service[ConfigDetector].flatMap(_.loadConfig)
|
||||
themeDir = config.themesDir / author.toLowerCase / themeName.toLowerCase
|
||||
|
||||
// 4. Check existence
|
||||
scaffold <- ZIO.service[ScaffoldService]
|
||||
exists <- scaffold.exists(themeDir)
|
||||
_ <- ZIO.when(exists) {
|
||||
ZIO.fail(ScaffoldError.AlreadyExists(themeDir.toString))
|
||||
}
|
||||
|
||||
// 5. Generate files based on template
|
||||
vars = TemplateVars(themeName, author, themeName)
|
||||
files = template match
|
||||
case Blank => blankThemeFiles(themeDir, vars)
|
||||
case Starter => starterThemeFiles(themeDir, vars)
|
||||
|
||||
// 6. Create with progress
|
||||
console <- ZIO.service[ConsoleOutput]
|
||||
progress <- ZIO.service[Progress]
|
||||
|
||||
_ <- if dryRun then
|
||||
ZIO.foreach(files) { case (path, _) =>
|
||||
console.info(s"Would create: $path")
|
||||
}
|
||||
else
|
||||
for
|
||||
handle <- progress.start(files.length, "Creating theme")
|
||||
_ <- ZIO.foreachDiscard(files.zipWithIndex) { case ((path, content), idx) =>
|
||||
scaffold.writeFile(path, content) *>
|
||||
progress.update(handle, idx + 1)
|
||||
}
|
||||
_ <- progress.complete(handle)
|
||||
yield ()
|
||||
|
||||
// 7. Next steps
|
||||
_ <- console.nextSteps(List(
|
||||
s"Edit theme.yaml to configure your theme",
|
||||
template match
|
||||
case Starter =>
|
||||
s"Run 'cd themes/${author.toLowerCase}/${themeName.toLowerCase} && npm install' to install dependencies"
|
||||
case Blank =>
|
||||
"Add layouts, pages, and partials to your theme"
|
||||
))
|
||||
yield ()
|
||||
|
||||
private def blankThemeFiles(dir: os.Path, vars: TemplateVars): List[(os.Path, String)] =
|
||||
List(
|
||||
(dir / "theme.yaml", themeManifest(vars)),
|
||||
(dir / "layouts" / "default.htm", blankLayout),
|
||||
(dir / "pages" / "home.htm", blankHomePage),
|
||||
(dir / "pages" / "404.htm", blank404Page),
|
||||
(dir / "partials" / ".gitkeep", ""),
|
||||
(dir / "assets" / "css" / ".gitkeep", ""),
|
||||
(dir / "assets" / "js" / ".gitkeep", "")
|
||||
)
|
||||
|
||||
private def starterThemeFiles(dir: os.Path, vars: TemplateVars): List[(os.Path, String)] =
|
||||
blankThemeFiles(dir, vars) ++ List(
|
||||
(dir / "package.json", packageJson(vars)),
|
||||
(dir / "vite.config.js", viteConfig),
|
||||
(dir / ".gitignore", themeGitignore),
|
||||
// Tailwind starter files
|
||||
(dir / "assets" / "css" / "app.css", tailwindCss),
|
||||
(dir / "assets" / "js" / "app.js", starterJs)
|
||||
)
|
||||
```
|
||||
|
||||
### Component Scaffold (Plugin Required)
|
||||
```scala
|
||||
// Source: WinterCMS CreateComponent pattern + CONTEXT.md decisions
|
||||
object ComponentScaffold:
|
||||
def create(
|
||||
pluginArg: String,
|
||||
componentName: String,
|
||||
dryRun: Boolean
|
||||
): ZIO[ScaffoldEnv, ScaffoldError, Unit] =
|
||||
for
|
||||
// Component requires explicit plugin
|
||||
(author, plugin) <- ZIO.fromEither(parsePluginName(pluginArg))
|
||||
|
||||
// Verify plugin exists
|
||||
config <- ZIO.service[ConfigDetector].flatMap(_.loadConfig)
|
||||
pluginDir = config.pluginsDir / author.toLowerCase / plugin.toLowerCase
|
||||
scaffold <- ZIO.service[ScaffoldService]
|
||||
exists <- scaffold.exists(pluginDir / "Plugin.scala")
|
||||
_ <- ZIO.unless(exists) {
|
||||
ZIO.fail(ScaffoldError.PluginNotFound(pluginArg))
|
||||
}
|
||||
|
||||
// Build paths and vars
|
||||
componentDir = pluginDir / "components"
|
||||
vars = TemplateVars(componentName, author, plugin)
|
||||
|
||||
files = List(
|
||||
(componentDir / s"${vars.studlyName}.scala", componentClass(vars)),
|
||||
(componentDir / vars.lowerName / "default.htm", componentPartial(vars)),
|
||||
(componentDir / vars.lowerName / "component.yaml", componentConfig(vars))
|
||||
)
|
||||
|
||||
console <- ZIO.service[ConsoleOutput]
|
||||
_ <- ZIO.foreach(files) { case (path, content) =>
|
||||
val rendered = TemplateRenderer.live.render(content, vars)
|
||||
if dryRun then
|
||||
console.info(s"Would create: $path")
|
||||
else
|
||||
scaffold.writeFile(path, rendered) *>
|
||||
console.success(s"Created: $path")
|
||||
}
|
||||
|
||||
_ <- console.nextSteps(List(
|
||||
s"Register component in Plugin.scala register() method",
|
||||
s"Edit ${vars.studlyName}.scala to add component logic",
|
||||
s"Edit default.htm partial for component output"
|
||||
))
|
||||
yield ()
|
||||
|
||||
private def componentClass(vars: TemplateVars): String = s"""
|
||||
|package ${vars.pluginNamespace}.components
|
||||
|
|
||||
|import com.summercms.component.*
|
||||
|import zio.*
|
||||
|
|
||||
|// TODO: Implement your component
|
||||
|class ${vars.studlyName} extends SummerComponent:
|
||||
| def componentDetails: ComponentDetails =
|
||||
| ComponentDetails(
|
||||
| name = "${vars.titleName}",
|
||||
| description = "TODO: Add description"
|
||||
| )
|
||||
|
|
||||
| def defineProperties: List[PropertyDef] =
|
||||
| List.empty
|
||||
|
|
||||
| def onRun: ZIO[ComponentEnv, ComponentError, Map[String, Any]] =
|
||||
| ZIO.succeed(Map.empty)
|
||||
|""".stripMargin
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Scopt for CLI parsing | ZIO CLI | 2023+ | ZIO-native, wizard mode, shell completion |
|
||||
| java.io.File | os-lib | 2020+ | Cross-platform, better API, safer paths |
|
||||
| Manual ANSI codes | fansi | 2018+ | Terminal detection, efficient, composable |
|
||||
| Scalate for templates | String interpolation | Always for simple | No dependency, fast, debuggable |
|
||||
| Interactive readline | JLine/ZIO CLI wizard | JLine 4.x | Menu selection, history, completion |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- scopt: Less maintained, no wizard mode
|
||||
- scallop: Macro-based, compile-time issues with Scala 3
|
||||
- java.io.File: Not cross-platform, verbose API
|
||||
- Manual ANSI escape codes: Brittle, terminal-specific
|
||||
|
||||
## Open Questions
|
||||
|
||||
Things that couldn't be fully resolved:
|
||||
|
||||
1. **Shell Completion Installation**
|
||||
- What we know: ZIO CLI generates completion scripts via `--shell-completion-script`
|
||||
- What's unclear: Best UX for installing completions (manual copy vs auto-install command)
|
||||
- Recommendation: Add `summer completion install` command that writes to appropriate shell config
|
||||
|
||||
2. **Native Binary Distribution**
|
||||
- What we know: Mill can create fat JARs; GraalVM native-image possible but complex
|
||||
- What's unclear: Whether startup time warrants native compilation
|
||||
- Recommendation: Start with fat JAR via Mill assembly; evaluate native-image later based on user feedback
|
||||
|
||||
3. **Plugin Templates Beyond Basic**
|
||||
- What we know: WinterCMS has options like `--controller`, `--all` on model create
|
||||
- What's unclear: Full set of template combinations needed
|
||||
- Recommendation: Start with basic scaffolds, add templates based on common patterns observed
|
||||
|
||||
4. **BuildInfo Generation**
|
||||
- What we know: Need version/Scala info in CLI output
|
||||
- What's unclear: Best Mill plugin for BuildInfo generation
|
||||
- Recommendation: Use mill-buildinfo or hand-roll with Mill's T.ctx() for simple cases
|
||||
|
||||
5. **Wizard Mode Default Behavior**
|
||||
- What we know: ZIO CLI wizard is opt-in with `--wizard` flag
|
||||
- What's unclear: Should wizard be default when required args missing vs error?
|
||||
- Recommendation: Per CONTEXT.md, prompt interactively when required args missing (make wizard default)
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [ZIO CLI Documentation](https://zio.dev/zio-cli/) - Commands, options, wizard mode, shell completion
|
||||
- [ZIO CLI GitHub](https://github.com/zio/zio-cli) - Version 0.7.4, API reference
|
||||
- [os-lib GitHub](https://github.com/com-lihaoyi/os-lib) - Version 0.11.7, file operations
|
||||
- [fansi GitHub](https://github.com/com-lihaoyi/fansi) - Version 0.5.1, terminal colors
|
||||
- [Mill Bundled Libraries](https://mill-build.org/mill/fundamentals/bundled-libraries.html) - os-lib, MainArgs integration
|
||||
- [WinterCMS Scaffolding Commands](https://wintercms.com/docs/v1.2/docs/console/scaffolding) - Reference patterns
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [MainArgs GitHub](https://github.com/com-lihaoyi/mainargs) - Version 0.7.7, alternative CLI parser
|
||||
- [JLine 3 GitHub](https://github.com/jline/jline3) - Version 4.0.0, advanced terminal input
|
||||
- [ZIO CLI Built-in Commands](https://zio.dev/zio-cli/built-in-commands/) - Wizard and help features
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Community blog posts on ZIO CLI usage
|
||||
- Stack Overflow answers on CLI patterns
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - ZIO CLI, os-lib, fansi are well-documented with official sources
|
||||
- Command structure: HIGH - Based on ZIO CLI docs and CONTEXT.md locked decisions
|
||||
- File operations: HIGH - os-lib API is well-documented, Mill integration verified
|
||||
- Template rendering: HIGH - Simple string interpolation, WinterCMS patterns verified
|
||||
- Interactive prompts: MEDIUM - ZIO CLI wizard documented, JLine for advanced cases
|
||||
- Progress bar: MEDIUM - Custom implementation, simple approach sufficient
|
||||
|
||||
**Research date:** 2026-02-05
|
||||
**Valid until:** 2026-03-05 (30 days - CLI libraries are stable)
|
||||
Reference in New Issue
Block a user