Files
Jakub Zych 5f2e7b87c9 fix(02): revise plans based on checker feedback
- 02-01 Task 3: Add concrete REPL verification command for PluginManifest.parse
- 02-02 Task 1: Add FieldDef case class to placeholder types in PluginRegistration.scala
- 02-02 Task 1: Document PluginEnv as placeholder to be expanded in 02-03
- 02-03 Task 3: Remove duplicate Main.scala code, keep only corrected version
- 02-03: Add SummerPlugin.scala to files_modified for PluginEnv update
2026-02-05 13:29:45 +01:00

328 lines
11 KiB
Markdown

---
phase: 02-plugin-system
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- build.mill
- summercms/src/plugin/PluginId.scala
- summercms/src/plugin/PluginManifest.scala
- summercms/src/plugin/PluginError.scala
- summercms/src/plugin/PluginDiscovery.scala
- plugins/.gitkeep
autonomous: true
must_haves:
truths:
- "Developer can write a plugin.yaml with vendor, name, version, dependencies"
- "System discovers all plugin.yaml files in plugins/ directory"
- "Invalid YAML manifests produce clear error messages"
- "Dependency version constraints parse correctly (^1.2.0, >=1.0.0)"
artifacts:
- path: "summercms/src/plugin/PluginManifest.scala"
provides: "Plugin manifest case class with circe derivation"
contains: "case class PluginManifest"
- path: "summercms/src/plugin/PluginDiscovery.scala"
provides: "Service to discover plugins from filesystem"
exports: ["PluginDiscovery", "DiscoveredPlugin"]
- path: "summercms/src/plugin/PluginId.scala"
provides: "Plugin identifier (vendor.name)"
contains: "case class PluginId"
- path: "summercms/src/plugin/PluginError.scala"
provides: "Plugin error ADT"
contains: "enum PluginError"
key_links:
- from: "summercms/src/plugin/PluginDiscovery.scala"
to: "summercms/src/plugin/PluginManifest.scala"
via: "parseManifest"
pattern: "parser\\.parse.*as\\[PluginManifest\\]"
- from: "build.mill"
to: "circe-yaml"
via: "mvnDeps"
pattern: "circe-yaml"
---
<objective>
Create the plugin discovery and manifest parsing foundation for SummerCMS.
Purpose: All plugins need manifests parsed from YAML. Discovery finds plugins automatically. This is the foundation that lifecycle management (02-02) builds upon.
Output: PluginManifest case class, PluginDiscovery service, PluginId/PluginError types, dependencies added to build.mill
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-plugin-system/02-RESEARCH.md
@.planning/phases/02-plugin-system/02-CONTEXT.md
@build.mill
@summercms/src/Main.scala
</context>
<tasks>
<task type="auto">
<name>Task 1: Add YAML and version parsing dependencies</name>
<files>build.mill</files>
<action>
Add the following dependencies to build.mill mvnDeps:
```scala
// YAML parsing for plugin manifests
mvn"io.circe::circe-yaml:0.16.1",
mvn"io.circe::circe-generic:0.14.10",
mvn"io.circe::circe-parser:0.14.10",
// Semver version constraints
mvn"com.github.sh4869::semver-parser-scala:0.0.4",
```
Add after the existing Flyway dependencies.
Note: circe-parser is needed alongside circe-yaml for JSON operations.
</action>
<verify>Run `./mill summercms.compile` - should succeed with new dependencies resolved</verify>
<done>build.mill contains circe-yaml, circe-generic, circe-parser, and semver-parser-scala dependencies</done>
</task>
<task type="auto">
<name>Task 2: Create plugin type foundations</name>
<files>
summercms/src/plugin/PluginId.scala
summercms/src/plugin/PluginError.scala
</files>
<action>
Create `summercms/src/plugin/` directory with these files:
**PluginId.scala:**
```scala
package plugin
/** Plugin identifier combining vendor and name */
case class PluginId(vendor: String, name: String):
/** Full identifier string (vendor.name) */
def fullId: String = s"$vendor.$name"
override def toString: String = fullId
object PluginId:
/** Parse "vendor.name" string into PluginId */
def parse(s: String): Either[String, PluginId] =
s.split('.').toList match
case vendor :: name :: Nil if vendor.nonEmpty && name.nonEmpty =>
Right(PluginId(vendor, name))
case _ =>
Left(s"Invalid plugin ID '$s': expected 'vendor.name' format")
/** Unsafe parse - throws on invalid format */
def unsafeParse(s: String): PluginId =
parse(s).getOrElse(throw new IllegalArgumentException(s"Invalid plugin ID: $s"))
```
**PluginError.scala:**
```scala
package plugin
import java.nio.file.Path
/** Plugin system error ADT */
enum PluginError:
case ManifestNotFound(path: Path)
case ManifestParseError(path: Path, message: String)
case InvalidPluginId(raw: String, reason: String)
case DependencyNotFound(plugin: PluginId, dependency: String)
case VersionMismatch(plugin: PluginId, dependency: String, required: String, actual: String)
case CyclicDependency(plugins: Set[PluginId])
case BootError(plugin: PluginId, cause: Throwable)
case ShutdownError(plugin: PluginId, cause: Throwable)
def message: String = this match
case ManifestNotFound(p) => s"Plugin manifest not found: $p"
case ManifestParseError(p, m) => s"Failed to parse manifest at $p: $m"
case InvalidPluginId(r, reason) => s"Invalid plugin ID '$r': $reason"
case DependencyNotFound(p, d) => s"Plugin ${p.fullId} requires $d which is not installed"
case VersionMismatch(p, d, req, act) => s"Plugin ${p.fullId} requires $d $req but found $act"
case CyclicDependency(ps) => s"Cyclic dependency detected: ${ps.map(_.fullId).mkString(" -> ")}"
case BootError(p, c) => s"Plugin ${p.fullId} failed to boot: ${c.getMessage}"
case ShutdownError(p, c) => s"Plugin ${p.fullId} failed to shutdown: ${c.getMessage}"
```
</action>
<verify>Run `./mill summercms.compile` - plugin package compiles without errors</verify>
<done>PluginId case class with parse methods, PluginError ADT with all error cases</done>
</task>
<task type="auto">
<name>Task 3: Create manifest parsing and discovery service</name>
<files>
summercms/src/plugin/PluginManifest.scala
summercms/src/plugin/PluginDiscovery.scala
plugins/.gitkeep
</files>
<action>
**PluginManifest.scala:**
```scala
package plugin
import io.circe.*
import io.circe.generic.auto.*
import io.circe.yaml.parser
import java.nio.file.Path
/** Plugin manifest parsed from plugin.yaml */
case class PluginManifest(
vendor: String,
name: String,
version: String,
description: Option[String] = None,
author: Option[String] = None,
homepage: Option[String] = None,
license: Option[String] = None,
dependencies: Map[String, String] = Map.empty, // "vendor.name" -> "^1.2.0"
optionalDependencies: Map[String, String] = Map.empty,
priority: Int = 0, // Higher = boots first among peers
enabled: Boolean = true
):
/** Derived plugin ID */
def id: PluginId = PluginId(vendor, name)
object PluginManifest:
/** Parse YAML string into PluginManifest */
def parse(yaml: String): Either[PluginError, PluginManifest] =
parser.parse(yaml)
.flatMap(_.as[PluginManifest])
.left.map(e => PluginError.ManifestParseError(
java.nio.file.Path.of("unknown"),
e.getMessage
))
/** Parse YAML file at path */
def parseFile(path: Path, content: String): Either[PluginError, PluginManifest] =
parser.parse(content)
.flatMap(_.as[PluginManifest])
.left.map(e => PluginError.ManifestParseError(path, e.getMessage))
```
**PluginDiscovery.scala:**
```scala
package plugin
import zio.*
import java.nio.file.{Files, Path, Paths}
import scala.jdk.CollectionConverters.*
/** A discovered plugin with its manifest and location */
case class DiscoveredPlugin(
directory: Path,
manifest: PluginManifest
):
def id: PluginId = manifest.id
/** Service for discovering plugins from filesystem */
trait PluginDiscovery:
/** Discover all plugins in the given directory */
def discover(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]]
object PluginDiscovery:
/** Default plugins directory */
val defaultPluginsDir: Path = Paths.get("plugins")
/** Access the discovery service */
def discover(pluginsDir: Path): ZIO[PluginDiscovery, PluginError, List[DiscoveredPlugin]] =
ZIO.serviceWithZIO[PluginDiscovery](_.discover(pluginsDir))
/** Live implementation that scans filesystem */
val live: ULayer[PluginDiscovery] = ZLayer.succeed {
new PluginDiscovery:
def discover(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]] =
for
exists <- ZIO.attemptBlocking(Files.exists(pluginsDir))
.orDie
plugins <- if !exists then ZIO.succeed(List.empty)
else findAndParseManifests(pluginsDir)
yield plugins
private def findAndParseManifests(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]] =
ZIO.attemptBlocking {
// Walk up to 3 levels: plugins/vendor/name/plugin.yaml
Files.walk(pluginsDir, 3).iterator.asScala
.filter(p => p.getFileName.toString == "plugin.yaml")
.toList
}.orDie.flatMap { manifestPaths =>
ZIO.foreach(manifestPaths) { path =>
for
content <- ZIO.attemptBlocking(Files.readString(path))
.mapError(_ => PluginError.ManifestNotFound(path))
manifest <- ZIO.fromEither(PluginManifest.parseFile(path, content))
yield DiscoveredPlugin(path.getParent, manifest)
}
}
}
```
**Create plugins directory:**
Create `plugins/.gitkeep` (empty file) to ensure the plugins directory exists in git.
</action>
<verify>
Run `./mill summercms.compile` - all plugin files compile.
Create a test manifest at `plugins/test/sample/plugin.yaml`:
```yaml
vendor: test
name: sample
version: 1.0.0
description: Test plugin
dependencies:
golem15.user: "^1.0.0"
```
Then test discovery works by running this command in the Mill REPL:
```bash
./mill -i summercms.console
```
Once in the REPL, verify parsing:
```scala
import plugin._
val yaml = """
vendor: test
name: sample
version: 1.0.0
description: Test plugin
"""
val result = PluginManifest.parse(yaml)
println(result) // Should print Right(PluginManifest(test,sample,1.0.0,...))
```
Exit REPL with `:quit` after verification.
</verify>
<done>PluginManifest parses YAML manifests, PluginDiscovery scans plugins/ directory, plugins/ directory exists</done>
</task>
</tasks>
<verification>
1. `./mill summercms.compile` succeeds
2. Dependencies include circe-yaml:0.16.1, circe-generic:0.14.10, semver-parser-scala:0.0.4
3. Plugin directory structure exists: `plugins/`
4. All plugin types compile: PluginId, PluginError, PluginManifest, PluginDiscovery
</verification>
<success_criteria>
- build.mill has all new dependencies
- PluginId.parse("golem15.blog") returns Right(PluginId("golem15", "blog"))
- PluginManifest.parse(validYaml) returns Right(manifest)
- PluginDiscovery.live provides working service
- plugins/ directory exists with .gitkeep
</success_criteria>
<output>
After completion, create `.planning/phases/02-plugin-system/02-01-SUMMARY.md`
</output>