- 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
328 lines
11 KiB
Markdown
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>
|