Files
summercms-initial-research/.planning/phases/02-plugin-system/02-01-PLAN.md
Jakub Zych 336acf5572 docs(02): create phase plan
Phase 02: Plugin System
- 3 plans in 3 waves (sequential)
- 02-01: Plugin discovery and manifest parsing
- 02-02: Lifecycle management and dependency resolution
- 02-03: Extension API with type-safe plugin-to-plugin communication
- Ready for execution
2026-02-05 13:24:06 +01:00

10 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
build.mill
summercms/src/plugin/PluginId.scala
summercms/src/plugin/PluginManifest.scala
summercms/src/plugin/PluginError.scala
summercms/src/plugin/PluginDiscovery.scala
plugins/.gitkeep
true
truths artifacts key_links
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)
path provides contains
summercms/src/plugin/PluginManifest.scala Plugin manifest case class with circe derivation case class PluginManifest
path provides exports
summercms/src/plugin/PluginDiscovery.scala Service to discover plugins from filesystem
PluginDiscovery
DiscoveredPlugin
path provides contains
summercms/src/plugin/PluginId.scala Plugin identifier (vendor.name) case class PluginId
path provides contains
summercms/src/plugin/PluginError.scala Plugin error ADT enum PluginError
from to via pattern
summercms/src/plugin/PluginDiscovery.scala summercms/src/plugin/PluginManifest.scala parseManifest parser.parse.*as[PluginManifest]
from to via pattern
build.mill circe-yaml mvnDeps circe-yaml
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

<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

Task 2: Create plugin type foundations summercms/src/plugin/PluginId.scala summercms/src/plugin/PluginError.scala Create `summercms/src/plugin/` directory with these files:

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}"
Run `./mill summercms.compile` - plugin package compiles without errors PluginId case class with parse methods, PluginError ADT with all error cases Task 3: Create manifest parsing and discovery service summercms/src/plugin/PluginManifest.scala summercms/src/plugin/PluginDiscovery.scala plugins/.gitkeep **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. 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 verify parsing works by checking the code compiles and the types are correct. PluginManifest parses YAML manifests, PluginDiscovery scans plugins/ directory, plugins/ directory exists

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

<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>
After completion, create `.planning/phases/02-plugin-system/02-01-SUMMARY.md`