From 336acf557267d8d3df1d0dcf64e30bd362f840a1 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 5 Feb 2026 13:24:06 +0100 Subject: [PATCH] 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 --- .planning/ROADMAP.md | 12 +- .../phases/02-plugin-system/02-01-PLAN.md | 309 +++++++++++ .../phases/02-plugin-system/02-02-PLAN.md | 518 ++++++++++++++++++ .../phases/02-plugin-system/02-03-PLAN.md | 510 +++++++++++++++++ 4 files changed, 1343 insertions(+), 6 deletions(-) create mode 100644 .planning/phases/02-plugin-system/02-01-PLAN.md create mode 100644 .planning/phases/02-plugin-system/02-02-PLAN.md create mode 100644 .planning/phases/02-plugin-system/02-03-PLAN.md 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 + + + +After completion, create `.planning/phases/02-plugin-system/02-01-SUMMARY.md` + 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 + + + +After completion, create `.planning/phases/02-plugin-system/02-02-SUMMARY.md` + 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 + + + +After completion, create `.planning/phases/02-plugin-system/02-03-SUMMARY.md` +