- 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
11 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-plugin-system | 01 | execute | 1 |
|
true |
|
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
<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_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 Task 1: Add YAML and version parsing dependencies build.mill Add the following dependencies to build.mill mvnDeps:// 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.
Run ./mill summercms.compile - should succeed with new dependencies resolved
build.mill contains circe-yaml, circe-generic, circe-parser, and semver-parser-scala dependencies
PluginId.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:
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}"
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.
Run ./mill summercms.compile - all plugin files compile.
Create a test manifest at plugins/test/sample/plugin.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:
./mill -i summercms.console
Once in the REPL, verify parsing:
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.
PluginManifest parses YAML manifests, PluginDiscovery scans plugins/ directory, plugins/ directory exists
<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>