diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index bc4cea5..9541d43 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -52,12 +52,12 @@ Plans:
3. Plugin lifecycle executes in correct order (register, boot, shutdown)
4. Plugin dependencies resolve correctly (plugin A requiring plugin B loads B first)
5. Plugin can extend another plugin via type-safe extension API
-**Plans**: TBD
+**Plans**: 3 plans
Plans:
-- [ ] 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
+- [ ] 02-01-PLAN.md - Plugin discovery and manifest parsing (circe-yaml, PluginId, PluginManifest, PluginDiscovery)
+- [ ] 02-02-PLAN.md - Lifecycle management and dependency resolution (SummerPlugin trait, PluginManager, topological sort)
+- [ ] 02-03-PLAN.md - Extension API with type-safe plugin-to-plugin communication (EventService, ExtensionRegistry)
### Phase 3: Component System
**Goal**: Enable reusable UI components that respond to HTMX interactions
@@ -206,8 +206,8 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
-| 1. Foundation | 3/3 | ✓ Complete | 2026-02-05 |
-| 2. Plugin System | 0/3 | Not started | - |
+| 1. Foundation | 3/3 | Complete | 2026-02-05 |
+| 2. Plugin System | 0/3 | Planned | - |
| 3. Component System | 0/2 | Not started | - |
| 4. Theme Engine | 0/2 | Not started | - |
| 5. CLI Scaffolding | 0/2 | Not started | - |
diff --git a/.planning/phases/02-plugin-system/02-01-PLAN.md b/.planning/phases/02-plugin-system/02-01-PLAN.md
new file mode 100644
index 0000000..34453c9
--- /dev/null
+++ b/.planning/phases/02-plugin-system/02-01-PLAN.md
@@ -0,0 +1,309 @@
+---
+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"
+---
+
+
+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
+
+
+
+@/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
+@build.mill
+@summercms/src/Main.scala
+
+
+
+
+
+ Task 1: Add YAML and version parsing dependencies
+ build.mill
+
+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.
+
+ 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:**
+```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}"
+```
+
+ 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`:
+```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
+
+
+
+- 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
+
+
+
diff --git a/.planning/phases/02-plugin-system/02-02-PLAN.md b/.planning/phases/02-plugin-system/02-02-PLAN.md
new file mode 100644
index 0000000..efa9404
--- /dev/null
+++ b/.planning/phases/02-plugin-system/02-02-PLAN.md
@@ -0,0 +1,518 @@
+---
+phase: 02-plugin-system
+plan: 02
+type: execute
+wave: 2
+depends_on: ["02-01"]
+files_modified:
+ - summercms/src/plugin/SummerPlugin.scala
+ - summercms/src/plugin/PluginState.scala
+ - summercms/src/plugin/PluginContext.scala
+ - summercms/src/plugin/PluginRegistration.scala
+ - summercms/src/plugin/DependencyResolver.scala
+ - summercms/src/plugin/PluginManager.scala
+autonomous: true
+
+must_haves:
+ truths:
+ - "Plugins implement SummerPlugin trait with register/boot/shutdown hooks"
+ - "Plugin dependencies resolve in topological order (dependencies boot first)"
+ - "Circular dependencies are detected at discovery time with clear error"
+ - "Plugin state tracks through lifecycle: Discovered -> Registered -> Booting -> Running"
+ - "Boot failures disable the failed plugin and its dependents"
+ artifacts:
+ - path: "summercms/src/plugin/SummerPlugin.scala"
+ provides: "Plugin lifecycle trait"
+ contains: "trait SummerPlugin"
+ - path: "summercms/src/plugin/PluginManager.scala"
+ provides: "Manages plugin lifecycle"
+ exports: ["PluginManager"]
+ - path: "summercms/src/plugin/DependencyResolver.scala"
+ provides: "Topological sort for dependency ordering"
+ contains: "def resolve"
+ - path: "summercms/src/plugin/PluginState.scala"
+ provides: "Plugin state machine"
+ contains: "enum PluginState"
+ key_links:
+ - from: "summercms/src/plugin/PluginManager.scala"
+ to: "summercms/src/plugin/DependencyResolver.scala"
+ via: "dependency resolution before boot"
+ pattern: "DependencyResolver\\.resolve"
+ - from: "summercms/src/plugin/PluginManager.scala"
+ to: "summercms/src/plugin/SummerPlugin.scala"
+ via: "calls lifecycle hooks"
+ pattern: "plugin\\.(register|boot|shutdown)"
+---
+
+
+Implement plugin lifecycle management with dependency resolution for SummerCMS.
+
+Purpose: Plugins need to boot in correct dependency order with proper state tracking. This enables plugins to safely depend on and extend each other. The extension API (02-03) requires running plugins.
+
+Output: SummerPlugin trait, PluginManager service, DependencyResolver, PluginState enum, PluginContext/PluginRegistration types
+
+
+
+@/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
+@summercms/src/plugin/PluginId.scala
+@summercms/src/plugin/PluginError.scala
+@summercms/src/plugin/PluginManifest.scala
+@summercms/src/plugin/PluginDiscovery.scala
+
+
+
+
+
+ Task 1: Create plugin lifecycle types
+
+ summercms/src/plugin/PluginState.scala
+ summercms/src/plugin/PluginContext.scala
+ summercms/src/plugin/PluginRegistration.scala
+ summercms/src/plugin/SummerPlugin.scala
+
+
+**PluginState.scala:**
+```scala
+package plugin
+
+/** Plugin lifecycle state machine */
+enum PluginState:
+ case Discovered // Found on filesystem, manifest parsed
+ case Registered // register() completed successfully
+ case Booting // boot() in progress
+ case Running // boot() succeeded, plugin active
+ case Failed(error: PluginError) // boot() failed
+ case Disabled // Manually disabled via config
+ case ShuttingDown // shutdown() in progress
+ case Stopped // shutdown() completed
+
+ def isActive: Boolean = this == Running
+ def canBoot: Boolean = this == Registered
+ def canShutdown: Boolean = this == Running || this == Failed(null) // null check is loose, refine if needed
+```
+
+**PluginContext.scala:**
+```scala
+package plugin
+
+import java.nio.file.Path
+
+/** Context provided to plugins during registration and boot */
+case class PluginContext(
+ manifest: PluginManifest,
+ directory: Path,
+ config: Map[String, String] = Map.empty // Plugin-specific config from application.conf
+):
+ def id: PluginId = manifest.id
+ def vendor: String = manifest.vendor
+ def name: String = manifest.name
+ def version: String = manifest.version
+```
+
+**PluginRegistration.scala:**
+```scala
+package plugin
+
+/** Data returned from plugin's register() method - declarative, no effects */
+case class PluginRegistration(
+ // Components provided by this plugin (Phase 3)
+ components: List[ComponentDef] = List.empty,
+ // Permissions defined by this plugin (Phase 6)
+ permissions: List[PermissionDef] = List.empty,
+ // Navigation items for admin backend (Phase 8)
+ navigation: List[NavigationDef] = List.empty,
+ // Settings pages (Phase 8)
+ settings: List[SettingDef] = List.empty,
+ // Event subscriptions (Phase 2 extension API)
+ events: List[EventSubscription] = List.empty,
+ // Extensions to other plugins (Phase 2 extension API)
+ extensions: List[ExtensionDef] = List.empty
+)
+
+object PluginRegistration:
+ val empty: PluginRegistration = PluginRegistration()
+
+// Placeholder types - will be fleshed out in later phases
+case class ComponentDef(name: String, className: String)
+case class PermissionDef(code: String, label: String)
+case class NavigationDef(id: String, label: String, url: String, icon: String = "", order: Int = 0)
+case class SettingDef(key: String, label: String, icon: String = "")
+case class EventSubscription(eventType: String, handler: String)
+case class ExtensionDef(target: String, extensionClass: String)
+```
+
+**SummerPlugin.scala:**
+```scala
+package plugin
+
+import zio.*
+
+/** Base trait for all SummerCMS plugins */
+trait SummerPlugin:
+ /** Plugin identifier - must match manifest */
+ def id: PluginId
+
+ /**
+ * Synchronous registration phase - pure data, no effects.
+ * Called during discovery to collect plugin declarations.
+ */
+ def register(ctx: PluginContext): PluginRegistration = PluginRegistration.empty
+
+ /**
+ * Async boot phase - can perform effects.
+ * Called after all dependencies have booted.
+ * Use for: database setup, event subscriptions, service initialization.
+ */
+ def boot: ZIO[PluginEnv, PluginError, Unit] = ZIO.unit
+
+ /**
+ * Async shutdown phase - cleanup resources.
+ * Called in reverse dependency order during application shutdown.
+ */
+ def shutdown: ZIO[Any, Nothing, Unit] = ZIO.unit
+
+/** Environment available to plugins during boot */
+type PluginEnv = PluginContext
+```
+
+ Run `./mill summercms.compile` - all types compile without errors
+ PluginState enum with all lifecycle states, PluginContext, PluginRegistration with placeholder defs, SummerPlugin trait
+
+
+
+ Task 2: Create dependency resolver with topological sort
+ summercms/src/plugin/DependencyResolver.scala
+
+**DependencyResolver.scala:**
+```scala
+package plugin
+
+import scala.annotation.tailrec
+
+/** Resolves plugin load order using topological sort (Kahn's algorithm) */
+object DependencyResolver:
+
+ /**
+ * Resolve plugins into dependency order (dependencies first).
+ * Returns Left(CyclicDependency) if cycle detected.
+ * Returns Right(orderedPlugins) on success.
+ */
+ def resolve(
+ plugins: Map[PluginId, PluginManifest]
+ ): Either[PluginError, List[PluginId]] =
+ // Build graph: plugin -> plugins that depend on it
+ val dependents = buildDependentsGraph(plugins)
+
+ // Calculate in-degrees (number of dependencies each plugin has)
+ val inDegree = plugins.map { case (id, manifest) =>
+ id -> manifest.dependencies.keys.count { depStr =>
+ PluginId.parse(depStr).toOption.exists(plugins.contains)
+ }
+ }
+
+ // Find plugins with no dependencies (in-degree = 0)
+ val roots = inDegree.filter(_._2 == 0).keys.toList
+ .sortBy(id => -plugins(id).priority) // Higher priority first
+
+ // Run Kahn's algorithm
+ process(roots, List.empty, inDegree, dependents, plugins)
+
+ private def buildDependentsGraph(
+ plugins: Map[PluginId, PluginManifest]
+ ): Map[PluginId, Set[PluginId]] =
+ plugins.values.foldLeft(Map.empty[PluginId, Set[PluginId]]) { (acc, manifest) =>
+ manifest.dependencies.keys.foldLeft(acc) { (a, depStr) =>
+ PluginId.parse(depStr).toOption match
+ case Some(depId) if plugins.contains(depId) =>
+ // depId -> manifest.id means "manifest.id depends on depId"
+ a.updated(depId, a.getOrElse(depId, Set.empty) + manifest.id)
+ case _ => a // Skip invalid or missing dependencies
+ }
+ }
+
+ @tailrec
+ private def process(
+ queue: List[PluginId],
+ result: List[PluginId],
+ degrees: Map[PluginId, Int],
+ dependents: Map[PluginId, Set[PluginId]],
+ plugins: Map[PluginId, PluginManifest]
+ ): Either[PluginError, List[PluginId]] =
+ queue match
+ case Nil =>
+ // If we processed all plugins, success; otherwise cycle detected
+ if result.size == plugins.size then Right(result.reverse)
+ else
+ val remaining = plugins.keySet -- result.toSet
+ Left(PluginError.CyclicDependency(remaining))
+
+ case head :: tail =>
+ // Process this plugin, decrement in-degrees of its dependents
+ val deps = dependents.getOrElse(head, Set.empty)
+ val newDegrees = deps.foldLeft(degrees) { (d, dep) =>
+ d.updated(dep, d(dep) - 1)
+ }
+
+ // Find newly ready plugins (in-degree became 0)
+ val newReady = deps
+ .filter(d => newDegrees(d) == 0)
+ .toList
+ .sortBy(id => -plugins(id).priority) // Higher priority first
+
+ process(tail ++ newReady, head :: result, newDegrees, dependents, plugins)
+
+ /**
+ * Validate that all dependencies exist and meet version constraints.
+ * Call this after resolve() to check version compatibility.
+ */
+ def validateDependencies(
+ plugins: Map[PluginId, PluginManifest]
+ ): List[PluginError] =
+ plugins.values.flatMap { manifest =>
+ manifest.dependencies.flatMap { case (depStr, constraint) =>
+ PluginId.parse(depStr).toOption match
+ case None =>
+ Some(PluginError.InvalidPluginId(depStr, "Invalid format in dependency"))
+ case Some(depId) =>
+ plugins.get(depId) match
+ case None =>
+ Some(PluginError.DependencyNotFound(manifest.id, depStr))
+ case Some(depManifest) =>
+ // TODO: Use semver-parser-scala to validate version constraint
+ // For now, just check dependency exists
+ None
+ }
+ }.toList
+```
+
+ Run `./mill summercms.compile` - DependencyResolver compiles. Test mentally: A depends on B, B depends on C -> resolve returns [C, B, A]
+ DependencyResolver.resolve() returns topologically sorted plugin IDs, detects cycles
+
+
+
+ Task 3: Create PluginManager service
+ summercms/src/plugin/PluginManager.scala
+
+**PluginManager.scala:**
+```scala
+package plugin
+
+import zio.*
+import java.nio.file.Path
+
+/** Manages plugin lifecycle: discovery, registration, boot, shutdown */
+trait PluginManager:
+ /** Discover and register all plugins from directory */
+ def loadPlugins(pluginsDir: Path): IO[PluginError, Unit]
+
+ /** Boot all registered plugins in dependency order */
+ def bootAll: IO[PluginError, Unit]
+
+ /** Shutdown all running plugins in reverse dependency order */
+ def shutdownAll: UIO[Unit]
+
+ /** Get current state of a plugin */
+ def getState(id: PluginId): UIO[Option[PluginState]]
+
+ /** Get all registered plugins */
+ def listPlugins: UIO[List[(PluginId, PluginState)]]
+
+ /** Get registration data for a plugin */
+ def getRegistration(id: PluginId): UIO[Option[PluginRegistration]]
+
+object PluginManager:
+ /** Access the manager service */
+ def loadPlugins(pluginsDir: Path): ZIO[PluginManager, PluginError, Unit] =
+ ZIO.serviceWithZIO[PluginManager](_.loadPlugins(pluginsDir))
+
+ def bootAll: ZIO[PluginManager, PluginError, Unit] =
+ ZIO.serviceWithZIO[PluginManager](_.bootAll)
+
+ def shutdownAll: ZIO[PluginManager, Nothing, Unit] =
+ ZIO.serviceWithZIO[PluginManager](_.shutdownAll)
+
+ def getState(id: PluginId): ZIO[PluginManager, Nothing, Option[PluginState]] =
+ ZIO.serviceWithZIO[PluginManager](_.getState(id))
+
+ def listPlugins: ZIO[PluginManager, Nothing, List[(PluginId, PluginState)]] =
+ ZIO.serviceWithZIO[PluginManager](_.listPlugins)
+
+ /** Live implementation */
+ val live: ZLayer[PluginDiscovery, Nothing, PluginManager] =
+ ZLayer.fromZIO {
+ for
+ discovery <- ZIO.service[PluginDiscovery]
+ pluginsRef <- Ref.make(Map.empty[PluginId, PluginInstance])
+ bootOrderRef <- Ref.make(List.empty[PluginId])
+ yield LivePluginManager(discovery, pluginsRef, bootOrderRef)
+ }
+
+/** Internal: plugin instance with state tracking */
+private case class PluginInstance(
+ discovered: DiscoveredPlugin,
+ plugin: Option[SummerPlugin], // None until loaded
+ registration: Option[PluginRegistration],
+ stateRef: Ref[PluginState]
+)
+
+private class LivePluginManager(
+ discovery: PluginDiscovery,
+ plugins: Ref[Map[PluginId, PluginInstance]],
+ bootOrder: Ref[List[PluginId]]
+) extends PluginManager:
+
+ def loadPlugins(pluginsDir: Path): IO[PluginError, Unit] =
+ for
+ discovered <- discovery.discover(pluginsDir)
+ // Filter enabled plugins only
+ enabled = discovered.filter(_.manifest.enabled)
+ // Build manifest map for dependency resolution
+ manifestMap = enabled.map(d => d.id -> d.manifest).toMap
+ // Validate dependencies exist
+ errors = DependencyResolver.validateDependencies(manifestMap)
+ _ <- ZIO.fail(errors.head).when(errors.nonEmpty)
+ // Resolve boot order
+ order <- ZIO.fromEither(DependencyResolver.resolve(manifestMap))
+ // Create plugin instances
+ instances <- ZIO.foreach(enabled) { d =>
+ Ref.make(PluginState.Discovered).map { stateRef =>
+ d.id -> PluginInstance(d, None, None, stateRef)
+ }
+ }
+ _ <- plugins.set(instances.toMap)
+ _ <- bootOrder.set(order)
+ // Register all plugins (synchronous phase)
+ _ <- ZIO.foreachDiscard(order)(registerPlugin)
+ yield ()
+
+ private def registerPlugin(id: PluginId): IO[PluginError, Unit] =
+ for
+ instance <- plugins.get.map(_.get(id)).someOrFail(
+ PluginError.DependencyNotFound(id, id.fullId)
+ )
+ // TODO: Load actual plugin class from directory
+ // For now, plugins are registered via code - manual registration
+ // In future: ClassLoader/reflection or compile-time wiring
+ ctx = PluginContext(instance.discovered.manifest, instance.discovered.directory)
+ // Mark as registered (actual plugin loading deferred)
+ _ <- instance.stateRef.set(PluginState.Registered)
+ yield ()
+
+ def bootAll: IO[PluginError, Unit] =
+ for
+ order <- bootOrder.get
+ _ <- ZIO.foreachDiscard(order)(bootPlugin)
+ yield ()
+
+ private def bootPlugin(id: PluginId): IO[PluginError, Unit] =
+ for
+ map <- plugins.get
+ instance <- ZIO.fromOption(map.get(id)).orElseFail(
+ PluginError.DependencyNotFound(id, id.fullId)
+ )
+ state <- instance.stateRef.get
+ _ <- ZIO.when(state.canBoot) {
+ for
+ _ <- instance.stateRef.set(PluginState.Booting)
+ // Boot the plugin if it exists
+ result <- instance.plugin match
+ case Some(p) =>
+ val ctx = PluginContext(instance.discovered.manifest, instance.discovered.directory)
+ p.boot.provideEnvironment(ZEnvironment(ctx)).either
+ case None =>
+ // No plugin class loaded yet - just mark as running
+ ZIO.succeed(Right(()))
+ _ <- result match
+ case Right(_) => instance.stateRef.set(PluginState.Running)
+ case Left(err) =>
+ instance.stateRef.set(PluginState.Failed(err)) *>
+ disableDependents(id)
+ yield ()
+ }
+ yield ()
+
+ private def disableDependents(failedId: PluginId): UIO[Unit] =
+ for
+ map <- plugins.get
+ // Find plugins that depend on failedId
+ dependents = map.filter { case (_, inst) =>
+ inst.discovered.manifest.dependencies.keys.exists { depStr =>
+ PluginId.parse(depStr).toOption.contains(failedId)
+ }
+ }
+ _ <- ZIO.foreachDiscard(dependents.values) { inst =>
+ inst.stateRef.set(PluginState.Disabled)
+ }
+ yield ()
+
+ def shutdownAll: UIO[Unit] =
+ for
+ order <- bootOrder.get
+ map <- plugins.get
+ // Shutdown in reverse order
+ _ <- ZIO.foreachDiscard(order.reverse) { id =>
+ map.get(id) match
+ case Some(inst) =>
+ for
+ state <- inst.stateRef.get
+ _ <- ZIO.when(state.isActive) {
+ inst.stateRef.set(PluginState.ShuttingDown) *>
+ inst.plugin.fold(ZIO.unit)(_.shutdown) *>
+ inst.stateRef.set(PluginState.Stopped)
+ }
+ yield ()
+ case None => ZIO.unit
+ }
+ yield ()
+
+ def getState(id: PluginId): UIO[Option[PluginState]] =
+ plugins.get.flatMap { map =>
+ map.get(id).fold(ZIO.none)(_.stateRef.get.map(Some(_)))
+ }
+
+ def listPlugins: UIO[List[(PluginId, PluginState)]] =
+ plugins.get.flatMap { map =>
+ ZIO.foreach(map.toList) { case (id, inst) =>
+ inst.stateRef.get.map(id -> _)
+ }
+ }
+
+ def getRegistration(id: PluginId): UIO[Option[PluginRegistration]] =
+ plugins.get.map(_.get(id).flatMap(_.registration))
+```
+
+ Run `./mill summercms.compile` - PluginManager compiles. The service can load, register, boot, and shutdown plugins.
+ PluginManager.live provides working service with full lifecycle management
+
+
+
+
+
+1. `./mill summercms.compile` succeeds with all lifecycle types
+2. SummerPlugin trait has register/boot/shutdown methods
+3. PluginState enum covers full lifecycle
+4. DependencyResolver.resolve returns topologically sorted IDs
+5. PluginManager.live boots plugins in correct dependency order
+
+
+
+- SummerPlugin trait is implementable by plugins
+- DependencyResolver detects cycles (returns Left(CyclicDependency))
+- DependencyResolver returns dependency-first order (A depends on B -> [B, A])
+- PluginManager tracks state through Discovered -> Registered -> Running
+- Boot failures disable dependent plugins
+
+
+
diff --git a/.planning/phases/02-plugin-system/02-03-PLAN.md b/.planning/phases/02-plugin-system/02-03-PLAN.md
new file mode 100644
index 0000000..6942fc8
--- /dev/null
+++ b/.planning/phases/02-plugin-system/02-03-PLAN.md
@@ -0,0 +1,510 @@
+---
+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
+
+
+