---
phase: 02-plugin-system
plan: 03
type: execute
wave: 3
depends_on: ["02-02"]
files_modified:
- summercms/src/plugin/EventService.scala
- summercms/src/plugin/ExtensionRegistry.scala
- summercms/src/plugin/SummerEvent.scala
- summercms/src/plugin/package.scala
- summercms/src/Main.scala
autonomous: true
must_haves:
truths:
- "Plugins can publish events to a central hub"
- "Plugins can subscribe to events from other plugins"
- "Plugins can register type-safe extensions for other plugins"
- "Extensions are discoverable by target plugin at boot time"
- "Plugin system integrates with Main.scala and boots on application start"
artifacts:
- path: "summercms/src/plugin/EventService.scala"
provides: "ZIO Hub-based pub/sub event system"
exports: ["EventService"]
- path: "summercms/src/plugin/ExtensionRegistry.scala"
provides: "Type-safe extension registration"
exports: ["ExtensionRegistry"]
- path: "summercms/src/plugin/SummerEvent.scala"
provides: "Base event trait and lifecycle events"
contains: "sealed trait SummerEvent"
- path: "summercms/src/plugin/package.scala"
provides: "Plugin package exports and type aliases"
contains: "type PluginLayer"
key_links:
- from: "summercms/src/Main.scala"
to: "summercms/src/plugin/PluginManager.scala"
via: "loads and boots plugins on startup"
pattern: "PluginManager\\.(loadPlugins|bootAll)"
- from: "summercms/src/plugin/ExtensionRegistry.scala"
to: "summercms/src/plugin/PluginManager.scala"
via: "extensions registered during plugin boot"
pattern: "ExtensionRegistry\\.register"
---
Implement the extension API with event system and type-safe extension registry for SummerCMS.
Purpose: Plugins need to communicate loosely via events and extend each other type-safely. This completes the plugin system, making it ready for components (Phase 3) and themes (Phase 4) to build upon.
Output: EventService (Hub-based), ExtensionRegistry, SummerEvent trait, plugin layer integration with Main.scala
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
@.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
@.planning/phases/02-plugin-system/02-01-SUMMARY.md
@.planning/phases/02-plugin-system/02-02-SUMMARY.md
@summercms/src/plugin/SummerPlugin.scala
@summercms/src/plugin/PluginManager.scala
@summercms/src/Main.scala
Task 1: Create event system with ZIO Hub
summercms/src/plugin/SummerEvent.scala
summercms/src/plugin/EventService.scala
**SummerEvent.scala:**
```scala
package plugin
import java.time.Instant
/** Base trait for all SummerCMS events */
sealed trait SummerEvent:
def timestamp: Instant = Instant.now()
object SummerEvent:
// ===== Plugin Lifecycle Events =====
/** Emitted when a plugin completes boot successfully */
case class PluginBooted(id: PluginId) extends SummerEvent
/** Emitted when a plugin fails to boot */
case class PluginBootFailed(id: PluginId, error: PluginError) extends SummerEvent
/** Emitted when a plugin begins shutdown */
case class PluginShuttingDown(id: PluginId) extends SummerEvent
/** Emitted when a plugin completes shutdown */
case class PluginStopped(id: PluginId) extends SummerEvent
// ===== Domain Events (plugins define their own) =====
/** Marker trait for domain-specific events from plugins */
trait DomainEvent extends SummerEvent
/** Example: User events (will be used by User plugin in Phase 10) */
trait UserEvent extends DomainEvent
case class UserCreated(userId: Long, email: String) extends UserEvent
case class UserLoggedIn(userId: Long) extends UserEvent
case class UserLoggedOut(userId: Long) extends UserEvent
/** Example: Content events (will be used by CMS in Phase 9) */
trait ContentEvent extends DomainEvent
case class PageCreated(pageId: Long, slug: String) extends ContentEvent
case class PagePublished(pageId: Long) extends ContentEvent
```
**EventService.scala:**
```scala
package plugin
import zio.*
import scala.reflect.ClassTag
/** Pub/sub event service using ZIO Hub */
trait EventService:
/** Publish an event to all subscribers */
def publish[E <: SummerEvent](event: E): UIO[Boolean]
/** Subscribe to all events - returns Dequeue in a Scope */
def subscribe: ZIO[Scope, Nothing, Dequeue[SummerEvent]]
/** Subscribe to events of a specific type */
def subscribeFiltered[E <: SummerEvent: ClassTag]: ZIO[Scope, Nothing, Dequeue[E]]
/** Subscribe with a handler function that processes events */
def subscribeWith[E <: SummerEvent: ClassTag](
handler: E => UIO[Unit]
): ZIO[Scope, Nothing, Fiber.Runtime[Nothing, Nothing]]
object EventService:
/** Publish an event */
def publish[E <: SummerEvent](event: E): ZIO[EventService, Nothing, Boolean] =
ZIO.serviceWithZIO[EventService](_.publish(event))
/** Subscribe to all events */
def subscribe: ZIO[EventService & Scope, Nothing, Dequeue[SummerEvent]] =
ZIO.serviceWithZIO[EventService](_.subscribe)
/** Subscribe to specific event type */
def subscribeFiltered[E <: SummerEvent: ClassTag]: ZIO[EventService & Scope, Nothing, Dequeue[E]] =
ZIO.serviceWithZIO[EventService](_.subscribeFiltered[E])
/** Live implementation with bounded Hub */
val live: ULayer[EventService] = ZLayer.scoped {
Hub.bounded[SummerEvent](256).map { hub =>
new EventService:
def publish[E <: SummerEvent](event: E): UIO[Boolean] =
hub.publish(event)
def subscribe: ZIO[Scope, Nothing, Dequeue[SummerEvent]] =
hub.subscribe
def subscribeFiltered[E <: SummerEvent: ClassTag]: ZIO[Scope, Nothing, Dequeue[E]] =
hub.subscribe.map { dequeue =>
new Dequeue[E]:
def take: UIO[E] =
dequeue.take.flatMap {
case e: E => ZIO.succeed(e)
case _ => take // Skip non-matching events
}
def takeAll: UIO[Chunk[E]] =
dequeue.takeAll.map(_.collect { case e: E => e })
def takeUpTo(max: Int): UIO[Chunk[E]] =
dequeue.takeUpTo(max).map(_.collect { case e: E => e })
def poll: UIO[Option[E]] =
dequeue.poll.map(_.collect { case e: E => e })
def size: UIO[Int] = dequeue.size
def capacity: Int = dequeue.capacity
def isShutdown: UIO[Boolean] = dequeue.isShutdown
def shutdown: UIO[Unit] = dequeue.shutdown
def awaitShutdown: UIO[Unit] = dequeue.awaitShutdown
}
def subscribeWith[E <: SummerEvent: ClassTag](
handler: E => UIO[Unit]
): ZIO[Scope, Nothing, Fiber.Runtime[Nothing, Nothing]] =
subscribeFiltered[E].flatMap { queue =>
queue.take.flatMap(handler).forever.fork
}
}
}
```
Run `./mill summercms.compile` - event system compiles. EventService.live provides a Hub-based pub/sub system.
SummerEvent trait with lifecycle and domain events, EventService with publish/subscribe via ZIO Hub
Task 2: Create extension registry
summercms/src/plugin/ExtensionRegistry.scala
**ExtensionRegistry.scala:**
```scala
package plugin
import zio.*
import scala.reflect.ClassTag
/**
* Registry for type-safe plugin extensions.
*
* Plugins can register extensions for other plugins:
* - SEO plugin registers BlogExtension for Blog plugin
* - Blog plugin queries registry for all BlogExtension implementations
*
* Extensions are keyed by trait type (ClassTag) and registered by plugin ID.
*/
trait ExtensionRegistry:
/** Register an extension from a plugin */
def register[E: ClassTag](pluginId: PluginId, extension: E): UIO[Unit]
/** Get all extensions of a type */
def getExtensions[E: ClassTag]: UIO[List[(PluginId, E)]]
/** Get extension from a specific plugin */
def getExtension[E: ClassTag](pluginId: PluginId): UIO[Option[E]]
/** Check if any extensions exist for a type */
def hasExtensions[E: ClassTag]: UIO[Boolean]
/** Remove all extensions from a plugin (used during unload/reload) */
def removePlugin(pluginId: PluginId): UIO[Unit]
object ExtensionRegistry:
/** Register an extension */
def register[E: ClassTag](pluginId: PluginId, extension: E): ZIO[ExtensionRegistry, Nothing, Unit] =
ZIO.serviceWithZIO[ExtensionRegistry](_.register(pluginId, extension))
/** Get all extensions of a type */
def getExtensions[E: ClassTag]: ZIO[ExtensionRegistry, Nothing, List[(PluginId, E)]] =
ZIO.serviceWithZIO[ExtensionRegistry](_.getExtensions[E])
/** Get extension from a specific plugin */
def getExtension[E: ClassTag](pluginId: PluginId): ZIO[ExtensionRegistry, Nothing, Option[E]] =
ZIO.serviceWithZIO[ExtensionRegistry](_.getExtension[E](pluginId))
/** Live implementation using Ref */
val live: ULayer[ExtensionRegistry] = ZLayer.fromZIO {
// Map from extension type key -> Map from plugin ID -> extension instance
Ref.make(Map.empty[String, Map[PluginId, Any]]).map { registry =>
new ExtensionRegistry:
private def typeKey[E: ClassTag]: String =
summon[ClassTag[E]].runtimeClass.getName
def register[E: ClassTag](pluginId: PluginId, extension: E): UIO[Unit] =
registry.update { map =>
val key = typeKey[E]
val existing = map.getOrElse(key, Map.empty)
map.updated(key, existing + (pluginId -> extension))
}
def getExtensions[E: ClassTag]: UIO[List[(PluginId, E)]] =
registry.get.map { map =>
map.getOrElse(typeKey[E], Map.empty)
.toList
.map { case (id, ext) => (id, ext.asInstanceOf[E]) }
}
def getExtension[E: ClassTag](pluginId: PluginId): UIO[Option[E]] =
registry.get.map { map =>
map.getOrElse(typeKey[E], Map.empty)
.get(pluginId)
.map(_.asInstanceOf[E])
}
def hasExtensions[E: ClassTag]: UIO[Boolean] =
registry.get.map { map =>
map.getOrElse(typeKey[E], Map.empty).nonEmpty
}
def removePlugin(pluginId: PluginId): UIO[Unit] =
registry.update { map =>
map.view.mapValues(_ - pluginId).toMap
}
}
}
/**
* Example extension trait that plugins can define.
* Other plugins implement this to extend the defining plugin.
*
* Example: Blog plugin defines BlogExtension, SEO plugin implements it.
*/
trait BlogExtension:
/** Additional fields to add to post forms */
def extendPostFields: List[FieldDef]
/** Transform post data before save */
def beforeSave(data: Map[String, Any]): Map[String, Any] = data
/** Validate post data */
def validate(data: Map[String, Any]): Either[String, Unit] = Right(())
// FieldDef is already defined in PluginRegistration.scala, reuse it
```
Run `./mill summercms.compile` - ExtensionRegistry compiles. It provides type-safe extension registration keyed by ClassTag.
ExtensionRegistry.live provides type-safe extension storage with register/get/remove operations
Task 3: Create plugin package exports and integrate with Main
summercms/src/plugin/package.scala
summercms/src/Main.scala
**package.scala:**
```scala
package object plugin:
/** Combined layer for all plugin services */
type PluginServices = PluginManager & PluginDiscovery & EventService & ExtensionRegistry
/** Create all plugin service layers */
val pluginLayer: zio.ZLayer[Any, Nothing, PluginServices] =
PluginDiscovery.live >>>
(PluginManager.live ++ EventService.live ++ ExtensionRegistry.live)
/** Environment provided to plugins during boot - includes all services */
type PluginBootEnv = PluginContext & EventService & ExtensionRegistry
```
**Update Main.scala** to integrate the plugin system:
```scala
import zio.*
import zio.http.*
import zio.config.typesafe.TypesafeConfigProvider
import api.Routes
import _root_.config.{AppConfig as SummerConfig}
import db.QuillContext
import plugin.{PluginManager, PluginDiscovery, pluginLayer}
object Main extends ZIOAppDefault {
private val banner: String =
"""
|
| | .
| `. * | .'
| `. ._|_* .' .
| . * .' `. *
| -------| |-------
| . *`.___.' * .
| .' |* `. *
| .' * | . `.
| . |
|
| S U M M E R C M S
|""".stripMargin
override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] =
Runtime.setConfigProvider(TypesafeConfigProvider.fromResourcePath())
override def run: ZIO[Any, Any, Any] =
for {
cfg <- ZIO.config[SummerConfig](SummerConfig.config)
_ <- Console.printLine(banner)
_ <- Console.printLine(s"Starting on port ${cfg.server.port}...")
_ <- Console.printLine("")
// Initialize plugin system
_ <- Console.printLine("Loading plugins...")
_ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir)
.catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(()))
_ <- PluginManager.bootAll
.catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(()))
plugins <- PluginManager.listPlugins
_ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)")
_ <- Console.printLine("")
// Note: Migrations are NOT auto-run. Use CLI to run migrations (Phase 5).
_ <- Server.serve(Routes.routes).provide(
Server.defaultWithPort(cfg.server.port),
QuillContext.dataSourceLayer
)
} yield ()
// Provide plugin layers
override def run: ZIO[Any, Any, Any] =
(for {
cfg <- ZIO.config[SummerConfig](SummerConfig.config)
_ <- Console.printLine(banner)
_ <- Console.printLine(s"Starting on port ${cfg.server.port}...")
_ <- Console.printLine("")
// Initialize plugin system
_ <- Console.printLine("Loading plugins...")
_ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir)
.catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(()))
_ <- PluginManager.bootAll
.catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(()))
plugins <- PluginManager.listPlugins
_ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)")
_ <- Console.printLine("")
// Note: Migrations are NOT auto-run. Use CLI to run migrations (Phase 5).
_ <- Server.serve(Routes.routes).provide(
Server.defaultWithPort(cfg.server.port),
QuillContext.dataSourceLayer
)
} yield ()).provide(pluginLayer)
```
Wait - the above has a duplicate `def run`. Let me provide the correct version:
**Main.scala (corrected full file):**
```scala
import zio.*
import zio.http.*
import zio.config.typesafe.TypesafeConfigProvider
import api.Routes
import _root_.config.{AppConfig as SummerConfig}
import db.QuillContext
import plugin.{PluginManager, PluginDiscovery, pluginLayer}
object Main extends ZIOAppDefault {
private val banner: String =
"""
|
| | .
| `. * | .'
| `. ._|_* .' .
| . * .' `. *
| -------| |-------
| . *`.___.' * .
| .' |* `. *
| .' * | . `.
| . |
|
| S U M M E R C M S
|""".stripMargin
override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] =
Runtime.setConfigProvider(TypesafeConfigProvider.fromResourcePath())
private val startupLogic: ZIO[PluginManager & PluginDiscovery, Any, Nothing] =
for {
cfg <- ZIO.config[SummerConfig](SummerConfig.config)
_ <- Console.printLine(banner)
_ <- Console.printLine(s"Starting on port ${cfg.server.port}...")
_ <- Console.printLine("")
// Initialize plugin system
_ <- Console.printLine("Loading plugins...")
_ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir)
.catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(()))
_ <- PluginManager.bootAll
.catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(()))
plugins <- PluginManager.listPlugins
_ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)")
_ <- Console.printLine("")
// Note: Migrations are NOT auto-run. Use CLI to run migrations (Phase 5).
res <- Server.serve(Routes.routes).provide(
Server.defaultWithPort(cfg.server.port),
QuillContext.dataSourceLayer
)
} yield res
override def run: ZIO[Any, Any, Any] =
startupLogic.provide(pluginLayer)
}
```
Run `./mill summercms.compile` - Main.scala compiles with plugin integration.
Run `./mill summercms.run` - Server starts, logs "Loading plugins..." and "Loaded 0 plugin(s)" (since no plugins exist yet).
Plugin package exports combined layer, Main.scala loads and boots plugins on startup
1. `./mill summercms.compile` succeeds
2. EventService.live provides Hub-based pub/sub
3. ExtensionRegistry.live provides type-safe extension storage
4. pluginLayer combines all services
5. Main.scala boots the application with plugin system initialization
6. Running `./mill summercms.run` shows "Loaded 0 plugin(s)" (no plugins installed)
- Events can be published and subscribed to via EventService
- Extensions can be registered and retrieved by type via ExtensionRegistry
- Application startup loads and boots plugins automatically
- System works with zero plugins (graceful empty state)
- Plugin system is ready for Component System (Phase 3) to build upon