diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index e8cb077..295b75a 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -26,15 +26,16 @@ "enum": [ "Build", "BuildAll", + "BuildApp", + "BuildModule", "Clean", "CleanPluginsArtifacts", + "Compile", "Default", "Pack", "Plugin", "Restore", "Run", - "StartAI", - "SyncAIManifest", "Test" ] }, @@ -125,17 +126,13 @@ "type": "string", "description": "Operation to perform: 'all' (default) or 'single'" }, - "role": { - "type": "string", - "description": "Role to filter context (e.g., Backend, Frontend, Plugin)" - }, "Solution": { "type": "string", "description": "Path to a solution file that is automatically loaded" }, - "verbose": { - "type": "boolean", - "description": "Verbosity for ManifestSync tool (true/false)" + "target-host": { + "type": "string", + "description": "Target host to build for: 'avalonia' (default), 'blazor', or 'all'" } } }, diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md deleted file mode 100644 index 7911cca..0000000 --- a/.specify/memory/constitution.md +++ /dev/null @@ -1,191 +0,0 @@ - - -# Modulus Constitution - -## Core Principles - -### UI-Agnostic Core - -- Core libraries in the Domain and Application layers MUST NOT reference any concrete UI - framework (for example `Microsoft.AspNetCore.Components`, `Avalonia`, or HTML/XAML types). -- All user interaction flows MUST be expressed in terms of `Modulus.UI.Abstractions` contracts - (such as `IUIFactory`, `IViewHost`, and related interfaces). -- Cross-cutting concerns (logging, configuration, localization) in core layers MUST remain - independent of any UI host and be injectable from the outside. -- Rationale: This keeps the business model portable across Blazor, Avalonia, CLI tools, and - future hosts. - -### Dual-Engine Host Architecture - -- The system MUST support at least two first-class hosts: `Modulus.Host.Blazor` (web ecosystem / - hybrid) and `Modulus.Host.Avalonia` (native rendering). -- Each module MAY provide multiple UI assemblies (for example `Module.UI.Blazor.dll`, - `Module.UI.Avalonia.dll`) that implement the same abstraction contracts for different hosts. -- Hosts are responsible for application shell concerns (windowing, routing, environment - integration) while modules are responsible for business logic and UI contracts only. -- Rationale: This allows the same module to be reused across lightweight web-style UIs and - high-performance native experiences. - -### Vertical Slice Modularity - -- The primary delivery unit is a module; each feature MUST be delivered as a module that - represents a vertical slice through the architecture. -- A module MAY implement one or more layers (for example Domain + Application only, or a full - stack including Presentation), but MUST declare its boundaries and registrations via - dependency injection. -- Runtime discovery MUST treat built-in features and external plugins uniformly, based on module - metadata and assembly scanning, without hidden "special core" modules. -- Rationale: Vertical slices keep features independently testable, deployable, and removable - without impacting unrelated areas. - -### Pyramid Layering - -- Dependencies MUST flow only in this direction: - Presentation → UI Abstraction → Application → Domain → Infrastructure. -- Cross-layer shortcuts (for example UI calling Infrastructure directly, or Application - depending on concrete UI frameworks) are forbidden. -- Communication between modules MUST occur via MediatR or well-defined interfaces, never - through direct coupling between feature implementations. -- Rationale: A strict dependency pyramid keeps the runtime composable, testable, and - host-agnostic. - -### AI-Friendly Contracts & Plugin SDK - -- Public plugin and module contracts MUST be strongly typed, self-describing, and use explicit - DTOs for inputs, outputs, and errors. -- The SDK MUST provide opinionated base classes and helpers (for example `BlazorToolPluginBase`, - `AvaloniaToolPluginBase`, and module base types) that encode recommended patterns for AI and - human authors. -- Any breaking change to public contracts MUST be versioned, documented in specifications, and - accompanied by migration guidance. -- Rationale: Clear contracts enable AI agents to generate high-quality plugins that compile and - behave correctly on first attempt. - -### Modern .NET & Technology Discipline - -- The project MUST target a current LTS or Current .NET runtime; introducing legacy frameworks - or outdated runtimes requires explicit justification and governance review. -- Core libraries MUST NOT depend on web-only constructs such as `HttpContext` or - environment-specific APIs that would prevent reuse across hosts. -- MediatR MUST be the default choice for in-process cross-module communication to avoid ad hoc - event or static coupling. -- ViewModel implementations MUST use `CommunityToolkit.Mvvm` (Source Generators, `ObservableObject`, `RelayCommand`) to standardize MVVM patterns and avoid boilerplate. -- Rationale: A disciplined, modern stack reduces maintenance cost and keeps the framework - portable. - -## Architecture & Additional Constraints - -### Module structure - -- Each feature MUST be represented as a module with a clear root namespace and assembly set, - typically following patterns such as `Modulus.Modules..Domain`, `...Application`, - `...Infrastructure`, and optional `...UI.Blazor` / `...UI.Avalonia`. -- Modules MAY implement only the layers they need (for example a pure infrastructure module or a - domain-only module), but MUST respect the dependency pyramid and expose clear integration - points. -- Module registration MUST be driven by DI and metadata (for example module attributes or - manifests), not by hard-coded lists in host applications. - -### Hosts and UI assemblies - -- Modules MAY ship separate UI assemblies for different hosts (for example `Module.UI.Blazor.dll` - and `Module.UI.Avalonia.dll`) implementing the same UI abstraction contracts. -- Hosts are responsible for resolving and loading the appropriate UI assemblies for the active - environment, leaving core assemblies reusable across all hosts. -- Presentation-layer projects MAY depend on host-specific frameworks (Blazor, Avalonia, - MAUI/Photino), but MUST only communicate with core logic through the UI abstraction layer, - Application services, and MediatR. - -### Plugin packaging and discovery - -- Plugin packages SHOULD use a structured container format (for example `.modpkg`) containing a - manifest plus assemblies for core and UI layers, as defined in the architecture docs. -- Plugin entry points MUST declare themselves via the Modulus SDK (for example module base - types or explicit plugin descriptors) so that discovery and unloading rely on clear contracts - rather than reflection heuristics. -- Runtime discovery MUST apply the same rules to built-in modules and external plugin packages - to ensure consistent behavior and isolation. -- Rationale: A consistent packaging and discovery model simplifies deployment, enables - hot-reload and unloading, and allows AI to generate deployable plugins. - -## Development Workflow & AI Collaboration - -### Planning and Constitution Check - -- Every implementation plan generated from `/speckit.plan` MUST include a "Constitution Check" - section that evaluates the feature against each core principle: - UI-agnostic core, dual-engine host architecture, vertical slice modularity, pyramid layering, - AI-friendly contracts, and modern .NET discipline. -- A plan MUST NOT proceed past Phase 0 research unless all identified constitutional risks have - either a mitigation plan or an explicit governance decision. - -### Specifications - -- Feature specifications (`/speckit.specify`) MUST state: - - Which module(s) own the feature as a vertical slice. - - Which host(s) (Blazor, Avalonia, or both) the feature targets. - - Any new or changed public contracts, DTOs, or SDK base types that affect plugins or AI - integration. -- Requirements MUST remain technology-agnostic at the Domain and Application layers, expressing - behavior without binding to a specific UI framework. - -### Tasks and implementation - -- Task breakdowns (`/speckit.tasks`) MUST group work by user story and also make module and host - boundaries explicit in task descriptions (for example which module and which UI assembly a - task touches). -- Foundational tasks MUST cover: - - Enforcing the dependency pyramid in project references and namespaces. - - Configuring MediatR for module-level and cross-module communication. - - Ensuring no Domain/Application projects reference concrete UI frameworks. -- Cross-cutting tasks MAY include constitution compliance checks, architecture reviews, and - updates to AI manifests used by `nuke StartAI`. - -### AI-assisted development - -- AI tools (for example GitHub Copilot) MUST consume up-to-date project context, including this - constitution, before generating significant architecture or plugin code. -- When generating plugins or modules, AI prompts and manifests SHOULD reference the official - SDK base types and contracts defined by Modulus, instead of ad hoc patterns. -- Changes produced with AI assistance MUST still pass all constitutional gates in plans, specs, - and reviews. - -## Governance - -- This constitution supersedes conflicting practices in older documentation or legacy code for - this repository. -- Amendments to the constitution MUST be proposed through design stories and specifications that - explain the motivation, affected modules, and migration considerations. -- The constitution uses semantic versioning in the form `MAJOR.MINOR.PATCH`: - - MAJOR: Backward-incompatible changes to principles or governance (for example removing or - redefining a principle). - - MINOR: New principles or sections added, or materially expanded guidance. - - PATCH: Clarifications, wording adjustments, and non-semantic refinements. -- All implementation plans MUST pass the Constitution Check before work begins, and pull - request reviews MUST verify that new code and modules adhere to UI agnosticism, dual host - support, vertical slice modularity, pyramid layering, AI-friendly contracts, and technology - discipline. -- Governance decisions and exceptions (if any) MUST be documented alongside the affected - features and referenced from the relevant plan and spec files. - -**Version**: 1.1.0 | **Ratified**: 2025-11-27 | **Last Amended**: 2025-12-01 - diff --git a/.specify/scripts/powershell/check-prerequisites.ps1 b/.specify/scripts/powershell/check-prerequisites.ps1 deleted file mode 100644 index 91667e9..0000000 --- a/.specify/scripts/powershell/check-prerequisites.ps1 +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env pwsh - -# Consolidated prerequisite checking script (PowerShell) -# -# This script provides unified prerequisite checking for Spec-Driven Development workflow. -# It replaces the functionality previously spread across multiple scripts. -# -# Usage: ./check-prerequisites.ps1 [OPTIONS] -# -# OPTIONS: -# -Json Output in JSON format -# -RequireTasks Require tasks.md to exist (for implementation phase) -# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list -# -PathsOnly Only output path variables (no validation) -# -Help, -h Show help message - -[CmdletBinding()] -param( - [switch]$Json, - [switch]$RequireTasks, - [switch]$IncludeTasks, - [switch]$PathsOnly, - [switch]$Help -) - -$ErrorActionPreference = 'Stop' - -# Show help if requested -if ($Help) { - Write-Output @" -Usage: check-prerequisites.ps1 [OPTIONS] - -Consolidated prerequisite checking for Spec-Driven Development workflow. - -OPTIONS: - -Json Output in JSON format - -RequireTasks Require tasks.md to exist (for implementation phase) - -IncludeTasks Include tasks.md in AVAILABLE_DOCS list - -PathsOnly Only output path variables (no prerequisite validation) - -Help, -h Show this help message - -EXAMPLES: - # Check task prerequisites (plan.md required) - .\check-prerequisites.ps1 -Json - - # Check implementation prerequisites (plan.md + tasks.md required) - .\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks - - # Get feature paths only (no validation) - .\check-prerequisites.ps1 -PathsOnly - -"@ - exit 0 -} - -# Source common functions -. "$PSScriptRoot/common.ps1" - -# Get feature paths and validate branch -$paths = Get-FeaturePathsEnv - -if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { - exit 1 -} - -# If paths-only mode, output paths and exit (support combined -Json -PathsOnly) -if ($PathsOnly) { - if ($Json) { - [PSCustomObject]@{ - REPO_ROOT = $paths.REPO_ROOT - BRANCH = $paths.CURRENT_BRANCH - FEATURE_DIR = $paths.FEATURE_DIR - FEATURE_SPEC = $paths.FEATURE_SPEC - IMPL_PLAN = $paths.IMPL_PLAN - TASKS = $paths.TASKS - } | ConvertTo-Json -Compress - } else { - Write-Output "REPO_ROOT: $($paths.REPO_ROOT)" - Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" - Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)" - Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)" - Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" - Write-Output "TASKS: $($paths.TASKS)" - } - exit 0 -} - -# Validate required directories and files -if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { - Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)" - Write-Output "Run /speckit.specify first to create the feature structure." - exit 1 -} - -if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { - Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)" - Write-Output "Run /speckit.plan first to create the implementation plan." - exit 1 -} - -# Check for tasks.md if required -if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) { - Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)" - Write-Output "Run /speckit.tasks first to create the task list." - exit 1 -} - -# Build list of available documents -$docs = @() - -# Always check these optional docs -if (Test-Path $paths.RESEARCH) { $docs += 'research.md' } -if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' } - -# Check contracts directory (only if it exists and has files) -if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) { - $docs += 'contracts/' -} - -if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' } - -# Include tasks.md if requested and it exists -if ($IncludeTasks -and (Test-Path $paths.TASKS)) { - $docs += 'tasks.md' -} - -# Output results -if ($Json) { - # JSON output - [PSCustomObject]@{ - FEATURE_DIR = $paths.FEATURE_DIR - AVAILABLE_DOCS = $docs - } | ConvertTo-Json -Compress -} else { - # Text output - Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)" - Write-Output "AVAILABLE_DOCS:" - - # Show status of each potential document - Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null - Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null - Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null - Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null - - if ($IncludeTasks) { - Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null - } -} diff --git a/.specify/scripts/powershell/common.ps1 b/.specify/scripts/powershell/common.ps1 deleted file mode 100644 index b0be273..0000000 --- a/.specify/scripts/powershell/common.ps1 +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env pwsh -# Common PowerShell functions analogous to common.sh - -function Get-RepoRoot { - try { - $result = git rev-parse --show-toplevel 2>$null - if ($LASTEXITCODE -eq 0) { - return $result - } - } catch { - # Git command failed - } - - # Fall back to script location for non-git repos - return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path -} - -function Get-CurrentBranch { - # First check if SPECIFY_FEATURE environment variable is set - if ($env:SPECIFY_FEATURE) { - return $env:SPECIFY_FEATURE - } - - # Then check git if available - try { - $result = git rev-parse --abbrev-ref HEAD 2>$null - if ($LASTEXITCODE -eq 0) { - return $result - } - } catch { - # Git command failed - } - - # For non-git repos, try to find the latest feature directory - $repoRoot = Get-RepoRoot - $specsDir = Join-Path $repoRoot "specs" - - if (Test-Path $specsDir) { - $latestFeature = "" - $highest = 0 - - Get-ChildItem -Path $specsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d{3})-') { - $num = [int]$matches[1] - if ($num -gt $highest) { - $highest = $num - $latestFeature = $_.Name - } - } - } - - if ($latestFeature) { - return $latestFeature - } - } - - # Final fallback - return "main" -} - -function Test-HasGit { - try { - git rev-parse --show-toplevel 2>$null | Out-Null - return ($LASTEXITCODE -eq 0) - } catch { - return $false - } -} - -function Test-FeatureBranch { - param( - [string]$Branch, - [bool]$HasGit = $true - ) - - # For non-git repos, we can't enforce branch naming but still provide output - if (-not $HasGit) { - Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" - return $true - } - - if ($Branch -notmatch '^[0-9]{3}-') { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name" - return $false - } - return $true -} - -function Get-FeatureDir { - param([string]$RepoRoot, [string]$Branch) - Join-Path $RepoRoot "specs/$Branch" -} - -function Get-FeaturePathsEnv { - $repoRoot = Get-RepoRoot - $currentBranch = Get-CurrentBranch - $hasGit = Test-HasGit - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch - - [PSCustomObject]@{ - REPO_ROOT = $repoRoot - CURRENT_BRANCH = $currentBranch - HAS_GIT = $hasGit - FEATURE_DIR = $featureDir - FEATURE_SPEC = Join-Path $featureDir 'spec.md' - IMPL_PLAN = Join-Path $featureDir 'plan.md' - TASKS = Join-Path $featureDir 'tasks.md' - RESEARCH = Join-Path $featureDir 'research.md' - DATA_MODEL = Join-Path $featureDir 'data-model.md' - QUICKSTART = Join-Path $featureDir 'quickstart.md' - CONTRACTS_DIR = Join-Path $featureDir 'contracts' - } -} - -function Test-FileExists { - param([string]$Path, [string]$Description) - if (Test-Path -Path $Path -PathType Leaf) { - Write-Output " ✓ $Description" - return $true - } else { - Write-Output " ✗ $Description" - return $false - } -} - -function Test-DirHasFiles { - param([string]$Path, [string]$Description) - if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) { - Write-Output " ✓ $Description" - return $true - } else { - Write-Output " ✗ $Description" - return $false - } -} - diff --git a/.specify/scripts/powershell/create-new-feature.ps1 b/.specify/scripts/powershell/create-new-feature.ps1 deleted file mode 100644 index 351f4e9..0000000 --- a/.specify/scripts/powershell/create-new-feature.ps1 +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env pwsh -# Create a new feature -[CmdletBinding()] -param( - [switch]$Json, - [string]$ShortName, - [int]$Number = 0, - [switch]$Help, - [Parameter(ValueFromRemainingArguments = $true)] - [string[]]$FeatureDescription -) -$ErrorActionPreference = 'Stop' - -# Show help if requested -if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] " - Write-Host "" - Write-Host "Options:" - Write-Host " -Json Output in JSON format" - Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" - Write-Host " -Number N Specify branch number manually (overrides auto-detection)" - Write-Host " -Help Show this help message" - Write-Host "" - Write-Host "Examples:" - Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'" - Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'" - exit 0 -} - -# Check if feature description provided -if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] " - exit 1 -} - -$featureDesc = ($FeatureDescription -join ' ').Trim() - -# Resolve repository root. Prefer git information when available, but fall back -# to searching for repository markers so the workflow still functions in repositories that -# were initialized with --no-git. -function Find-RepositoryRoot { - param( - [string]$StartDir, - [string[]]$Markers = @('.git', '.specify') - ) - $current = Resolve-Path $StartDir - while ($true) { - foreach ($marker in $Markers) { - if (Test-Path (Join-Path $current $marker)) { - return $current - } - } - $parent = Split-Path $current -Parent - if ($parent -eq $current) { - # Reached filesystem root without finding markers - return $null - } - $current = $parent - } -} - -function Get-HighestNumberFromSpecs { - param([string]$SpecsDir) - - $highest = 0 - if (Test-Path $SpecsDir) { - Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d+)') { - $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } - } - } - } - return $highest -} - -function Get-HighestNumberFromBranches { - param() - - $highest = 0 - try { - $branches = git branch -a 2>$null - if ($LASTEXITCODE -eq 0) { - foreach ($branch in $branches) { - # Clean branch name: remove leading markers and remote prefixes - $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' - - # Extract feature number if branch matches pattern ###-* - if ($cleanBranch -match '^(\d+)-') { - $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } - } - } - } - } catch { - # If git command fails, return 0 - Write-Verbose "Could not check Git branches: $_" - } - return $highest -} - -function Get-NextBranchNumber { - param( - [string]$ShortName, - [string]$SpecsDir - ) - - # Fetch all remotes to get latest branch info (suppress errors if no remotes) - try { - git fetch --all --prune 2>$null | Out-Null - } catch { - # Ignore fetch errors - } - - # Find remote branches matching the pattern using git ls-remote - $remoteBranches = @() - try { - $remoteRefs = git ls-remote --heads origin 2>$null - if ($remoteRefs) { - $remoteBranches = $remoteRefs | Where-Object { $_ -match "refs/heads/(\d+)-$([regex]::Escape($ShortName))$" } | ForEach-Object { - if ($_ -match "refs/heads/(\d+)-") { - [int]$matches[1] - } - } - } - } catch { - # Ignore errors - } - - # Check local branches - $localBranches = @() - try { - $allBranches = git branch 2>$null - if ($allBranches) { - $localBranches = $allBranches | Where-Object { $_ -match "^\*?\s*(\d+)-$([regex]::Escape($ShortName))$" } | ForEach-Object { - if ($_ -match "(\d+)-") { - [int]$matches[1] - } - } - } - } catch { - # Ignore errors - } - - # Check specs directory - $specDirs = @() - if (Test-Path $SpecsDir) { - try { - $specDirs = Get-ChildItem -Path $SpecsDir -Directory | Where-Object { $_.Name -match "^(\d+)-$([regex]::Escape($ShortName))$" } | ForEach-Object { - if ($_.Name -match "^(\d+)-") { - [int]$matches[1] - } - } - } catch { - # Ignore errors - } - } - - # Combine all sources and get the highest number - $maxNum = 0 - foreach ($num in ($remoteBranches + $localBranches + $specDirs)) { - if ($num -gt $maxNum) { - $maxNum = $num - } - } - - # Return next number - return $maxNum + 1 -} - -function ConvertTo-CleanBranchName { - param([string]$Name) - - return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' -} -$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) -if (-not $fallbackRoot) { - Write-Error "Error: Could not determine repository root. Please run this script from within the repository." - exit 1 -} - -try { - $repoRoot = git rev-parse --show-toplevel 2>$null - if ($LASTEXITCODE -eq 0) { - $hasGit = $true - } else { - throw "Git not available" - } -} catch { - $repoRoot = $fallbackRoot - $hasGit = $false -} - -Set-Location $repoRoot - -$specsDir = Join-Path $repoRoot 'specs' -New-Item -ItemType Directory -Path $specsDir -Force | Out-Null - -# Function to generate branch name with stop word filtering and length filtering -function Get-BranchName { - param([string]$Description) - - # Common stop words to filter out - $stopWords = @( - 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', - 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', - 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall', - 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', - 'want', 'need', 'add', 'get', 'set' - ) - - # Convert to lowercase and extract words (alphanumeric only) - $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' - $words = $cleanName -split '\s+' | Where-Object { $_ } - - # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) - $meaningfulWords = @() - foreach ($word in $words) { - # Skip stop words - if ($stopWords -contains $word) { continue } - - # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) - if ($word.Length -ge 3) { - $meaningfulWords += $word - } elseif ($Description -match "\b$($word.ToUpper())\b") { - # Keep short words if they appear as uppercase in original (likely acronyms) - $meaningfulWords += $word - } - } - - # If we have meaningful words, use first 3-4 of them - if ($meaningfulWords.Count -gt 0) { - $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } - $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' - return $result - } else { - # Fallback to original logic if no meaningful words found - $result = ConvertTo-CleanBranchName -Name $Description - $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 - return [string]::Join('-', $fallbackWords) - } -} - -# Generate branch name -if ($ShortName) { - # Use provided short name, just clean it up - $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName -} else { - # Generate from description with smart filtering - $branchSuffix = Get-BranchName -Description $featureDesc -} - -# Determine branch number -if ($Number -eq 0) { - if ($hasGit) { - # Check existing branches on remotes - $Number = Get-NextBranchNumber -ShortName $branchSuffix -SpecsDir $specsDir - } else { - # Fall back to local directory check - $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 - } -} - -$featureNum = ('{0:000}' -f $Number) -$branchName = "$featureNum-$branchSuffix" - -# GitHub enforces a 244-byte limit on branch names -# Validate and truncate if necessary -$maxBranchLength = 244 -if ($branchName.Length -gt $maxBranchLength) { - # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - $maxSuffixLength = $maxBranchLength - 4 - - # Truncate suffix - $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) - # Remove trailing hyphen if truncation created one - $truncatedSuffix = $truncatedSuffix -replace '-$', '' - - $originalBranchName = $branchName - $branchName = "$featureNum-$truncatedSuffix" - - Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" - Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" - Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" -} - -if ($hasGit) { - try { - git checkout -b $branchName | Out-Null - } catch { - Write-Warning "Failed to create git branch: $branchName" - } -} else { - Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" -} - -$featureDir = Join-Path $specsDir $branchName -New-Item -ItemType Directory -Path $featureDir -Force | Out-Null - -$template = Join-Path $repoRoot '.specify/templates/spec-template.md' -$specFile = Join-Path $featureDir 'spec.md' -if (Test-Path $template) { - Copy-Item $template $specFile -Force -} else { - New-Item -ItemType File -Path $specFile | Out-Null -} - -# Set the SPECIFY_FEATURE environment variable for the current session -$env:SPECIFY_FEATURE = $branchName - -if ($Json) { - $obj = [PSCustomObject]@{ - BRANCH_NAME = $branchName - SPEC_FILE = $specFile - FEATURE_NUM = $featureNum - HAS_GIT = $hasGit - } - $obj | ConvertTo-Json -Compress -} else { - Write-Output "BRANCH_NAME: $branchName" - Write-Output "SPEC_FILE: $specFile" - Write-Output "FEATURE_NUM: $featureNum" - Write-Output "HAS_GIT: $hasGit" - Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" -} - diff --git a/.specify/scripts/powershell/setup-plan.ps1 b/.specify/scripts/powershell/setup-plan.ps1 deleted file mode 100644 index d0ed582..0000000 --- a/.specify/scripts/powershell/setup-plan.ps1 +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env pwsh -# Setup implementation plan for a feature - -[CmdletBinding()] -param( - [switch]$Json, - [switch]$Help -) - -$ErrorActionPreference = 'Stop' - -# Show help if requested -if ($Help) { - Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]" - Write-Output " -Json Output results in JSON format" - Write-Output " -Help Show this help message" - exit 0 -} - -# Load common functions -. "$PSScriptRoot/common.ps1" - -# Get all paths and variables from common functions -$paths = Get-FeaturePathsEnv - -# Check if we're on a proper feature branch (only for git repos) -if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { - exit 1 -} - -# Ensure the feature directory exists -New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null - -# Copy plan template if it exists, otherwise note it or create empty file -$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md' -if (Test-Path $template) { - Copy-Item $template $paths.IMPL_PLAN -Force - Write-Output "Copied plan template to $($paths.IMPL_PLAN)" -} else { - Write-Warning "Plan template not found at $template" - # Create a basic plan file if template doesn't exist - New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null -} - -# Output results -if ($Json) { - $result = [PSCustomObject]@{ - FEATURE_SPEC = $paths.FEATURE_SPEC - IMPL_PLAN = $paths.IMPL_PLAN - SPECS_DIR = $paths.FEATURE_DIR - BRANCH = $paths.CURRENT_BRANCH - HAS_GIT = $paths.HAS_GIT - } - $result | ConvertTo-Json -Compress -} else { - Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)" - Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" - Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)" - Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" - Write-Output "HAS_GIT: $($paths.HAS_GIT)" -} diff --git a/.specify/scripts/powershell/update-agent-context.ps1 b/.specify/scripts/powershell/update-agent-context.ps1 deleted file mode 100644 index e887b2b..0000000 --- a/.specify/scripts/powershell/update-agent-context.ps1 +++ /dev/null @@ -1,445 +0,0 @@ -#!/usr/bin/env pwsh -<#! -.SYNOPSIS -Update agent context files with information from plan.md (PowerShell version) - -.DESCRIPTION -Mirrors the behavior of scripts/bash/update-agent-context.sh: - 1. Environment Validation - 2. Plan Data Extraction - 3. Agent File Management (create from template or update existing) - 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, bob) - -.PARAMETER AgentType -Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). - -.EXAMPLE -./update-agent-context.ps1 -AgentType claude - -.EXAMPLE -./update-agent-context.ps1 # Updates all existing agent files - -.NOTES -Relies on common helper functions in common.ps1 -#> -param( - [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','bob')] - [string]$AgentType -) - -$ErrorActionPreference = 'Stop' - -# Import common helpers -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -. (Join-Path $ScriptDir 'common.ps1') - -# Acquire environment paths -$envData = Get-FeaturePathsEnv -$REPO_ROOT = $envData.REPO_ROOT -$CURRENT_BRANCH = $envData.CURRENT_BRANCH -$HAS_GIT = $envData.HAS_GIT -$IMPL_PLAN = $envData.IMPL_PLAN -$NEW_PLAN = $IMPL_PLAN - -# Agent file paths -$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md' -$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md' -$COPILOT_FILE = Join-Path $REPO_ROOT '.github/agents/copilot-instructions.md' -$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc' -$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md' -$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md' -$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md' -$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md' -$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md' -$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md' -$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' -$Q_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' - -$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' - -# Parsed plan data placeholders -$script:NEW_LANG = '' -$script:NEW_FRAMEWORK = '' -$script:NEW_DB = '' -$script:NEW_PROJECT_TYPE = '' - -function Write-Info { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "INFO: $Message" -} - -function Write-Success { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "$([char]0x2713) $Message" -} - -function Write-WarningMsg { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Warning $Message -} - -function Write-Err { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "ERROR: $Message" -ForegroundColor Red -} - -function Validate-Environment { - if (-not $CURRENT_BRANCH) { - Write-Err 'Unable to determine current feature' - if ($HAS_GIT) { Write-Info "Make sure you're on a feature branch" } else { Write-Info 'Set SPECIFY_FEATURE environment variable or create a feature first' } - exit 1 - } - if (-not (Test-Path $NEW_PLAN)) { - Write-Err "No plan.md found at $NEW_PLAN" - Write-Info 'Ensure you are working on a feature with a corresponding spec directory' - if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' } - exit 1 - } - if (-not (Test-Path $TEMPLATE_FILE)) { - Write-Err "Template file not found at $TEMPLATE_FILE" - Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.' - exit 1 - } -} - -function Extract-PlanField { - param( - [Parameter(Mandatory=$true)] - [string]$FieldPattern, - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { return '' } - # Lines like **Language/Version**: Python 3.12 - $regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$" - Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object { - if ($_ -match $regex) { - $val = $Matches[1].Trim() - if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val } - } - } | Select-Object -First 1 -} - -function Parse-PlanData { - param( - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { Write-Err "Plan file not found: $PlanFile"; return $false } - Write-Info "Parsing plan data from $PlanFile" - $script:NEW_LANG = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile - $script:NEW_FRAMEWORK = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile - $script:NEW_DB = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile - $script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile - - if ($NEW_LANG) { Write-Info "Found language: $NEW_LANG" } else { Write-WarningMsg 'No language information found in plan' } - if ($NEW_FRAMEWORK) { Write-Info "Found framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info "Found database: $NEW_DB" } - if ($NEW_PROJECT_TYPE) { Write-Info "Found project type: $NEW_PROJECT_TYPE" } - return $true -} - -function Format-TechnologyStack { - param( - [Parameter(Mandatory=$false)] - [string]$Lang, - [Parameter(Mandatory=$false)] - [string]$Framework - ) - $parts = @() - if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang } - if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework } - if (-not $parts) { return '' } - return ($parts -join ' + ') -} - -function Get-ProjectStructure { - param( - [Parameter(Mandatory=$false)] - [string]$ProjectType - ) - if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" } -} - -function Get-CommandsForLanguage { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - switch -Regex ($Lang) { - 'Python' { return "cd src; pytest; ruff check ." } - 'Rust' { return "cargo test; cargo clippy" } - 'JavaScript|TypeScript' { return "npm test; npm run lint" } - default { return "# Add commands for $Lang" } - } -} - -function Get-LanguageConventions { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' } -} - -function New-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$ProjectName, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err "Template not found at $TEMPLATE_FILE"; return $false } - $temp = New-TemporaryFile - Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force - - $projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE - $commands = Get-CommandsForLanguage -Lang $NEW_LANG - $languageConventions = Get-LanguageConventions -Lang $NEW_LANG - - $escaped_lang = $NEW_LANG - $escaped_framework = $NEW_FRAMEWORK - $escaped_branch = $CURRENT_BRANCH - - $content = Get-Content -LiteralPath $temp -Raw -Encoding utf8 - $content = $content -replace '\[PROJECT NAME\]',$ProjectName - $content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd') - - # Build the technology stack string safely - $techStackForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $techStackForTemplate = "- $escaped_lang + $escaped_framework ($escaped_branch)" - } elseif ($escaped_lang) { - $techStackForTemplate = "- $escaped_lang ($escaped_branch)" - } elseif ($escaped_framework) { - $techStackForTemplate = "- $escaped_framework ($escaped_branch)" - } - - $content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate - # For project structure we manually embed (keep newlines) - $escapedStructure = [Regex]::Escape($projectStructure) - $content = $content -replace '\[ACTUAL STRUCTURE FROM PLANS\]',$escapedStructure - # Replace escaped newlines placeholder after all replacements - $content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands - $content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions - - # Build the recent changes string safely - $recentChangesForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}" - } elseif ($escaped_lang) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang}" - } elseif ($escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}" - } - - $content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate - # Convert literal \n sequences introduced by Escape to real newlines - $content = $content -replace '\\n',[Environment]::NewLine - - $parent = Split-Path -Parent $TargetFile - if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null } - Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8 - Remove-Item $temp -Force - return $true -} - -function Update-ExistingAgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) } - - $techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK - $newTechEntries = @() - if ($techStack) { - $escapedTechStack = [Regex]::Escape($techStack) - if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) { - $newTechEntries += "- $techStack ($CURRENT_BRANCH)" - } - } - if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { - $escapedDB = [Regex]::Escape($NEW_DB) - if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) { - $newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)" - } - } - $newChangeEntry = '' - if ($techStack) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${techStack}" } - elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${NEW_DB}" } - - $lines = Get-Content -LiteralPath $TargetFile -Encoding utf8 - $output = New-Object System.Collections.Generic.List[string] - $inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0 - - for ($i=0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -eq '## Active Technologies') { - $output.Add($line) - $inTech = $true - continue - } - if ($inTech -and $line -match '^##\s') { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); $inTech = $false; continue - } - if ($inTech -and [string]::IsNullOrWhiteSpace($line)) { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); continue - } - if ($line -eq '## Recent Changes') { - $output.Add($line) - if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true } - $inChanges = $true - continue - } - if ($inChanges -and $line -match '^##\s') { $output.Add($line); $inChanges = $false; continue } - if ($inChanges -and $line -match '^- ') { - if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ } - continue - } - if ($line -match '\*\*Last updated\*\*: .*\d{4}-\d{2}-\d{2}') { - $output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd'))) - continue - } - $output.Add($line) - } - - # Post-loop check: if we're still in the Active Technologies section and haven't added new entries - if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) { - $newTechEntries | ForEach-Object { $output.Add($_) } - } - - Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8 - return $true -} - -function Update-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$AgentName - ) - if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false } - Write-Info "Updating $AgentName context file: $TargetFile" - $projectName = Split-Path $REPO_ROOT -Leaf - $date = Get-Date - - $dir = Split-Path -Parent $TargetFile - if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } - - if (-not (Test-Path $TargetFile)) { - if (New-AgentFile -TargetFile $TargetFile -ProjectName $projectName -Date $date) { Write-Success "Created new $AgentName context file" } else { Write-Err 'Failed to create new agent file'; return $false } - } else { - try { - if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success "Updated existing $AgentName context file" } else { Write-Err 'Failed to update agent file'; return $false } - } catch { - Write-Err "Cannot access or update existing file: $TargetFile. $_" - return $false - } - } - return $true -} - -function Update-SpecificAgent { - param( - [Parameter(Mandatory=$true)] - [string]$Type - ) - switch ($Type) { - 'claude' { Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code' } - 'gemini' { Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI' } - 'copilot' { Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot' } - 'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE' } - 'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' } - 'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' } - 'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' } - 'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' } - 'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' } - 'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } - 'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } - 'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' } - 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } - 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } - 'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' } - 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob'; return $false } - } -} - -function Update-AllExistingAgents { - $found = $false - $ok = $true - if (Test-Path $CLAUDE_FILE) { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }; $found = $true } - if (Test-Path $GEMINI_FILE) { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true } - if (Test-Path $COPILOT_FILE) { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true } - if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true } - if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true } - if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true } - if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true } - if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true } - if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true } - if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true } - if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true } - if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true } - if (Test-Path $Q_FILE) { if (-not (Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI')) { $ok = $false }; $found = $true } - if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } - if (-not $found) { - Write-Info 'No existing agent files found, creating default Claude file...' - if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } - } - return $ok -} - -function Print-Summary { - Write-Host '' - Write-Info 'Summary of changes:' - if ($NEW_LANG) { Write-Host " - Added language: $NEW_LANG" } - if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } - Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob]' -} - -function Main { - Validate-Environment - Write-Info "=== Updating agent context files for feature $CURRENT_BRANCH ===" - if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 } - $success = $true - if ($AgentType) { - Write-Info "Updating specific agent: $AgentType" - if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false } - } - else { - Write-Info 'No agent specified, updating all existing agent files...' - if (-not (Update-AllExistingAgents)) { $success = $false } - } - Print-Summary - if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 } -} - -Main - diff --git a/.specify/templates/agent-file-template.md b/.specify/templates/agent-file-template.md deleted file mode 100644 index 4cc7fd6..0000000 --- a/.specify/templates/agent-file-template.md +++ /dev/null @@ -1,28 +0,0 @@ -# [PROJECT NAME] Development Guidelines - -Auto-generated from all feature plans. Last updated: [DATE] - -## Active Technologies - -[EXTRACTED FROM ALL PLAN.MD FILES] - -## Project Structure - -```text -[ACTUAL STRUCTURE FROM PLANS] -``` - -## Commands - -[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] - -## Code Style - -[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] - -## Recent Changes - -[LAST 3 FEATURES AND WHAT THEY ADDED] - - - diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md deleted file mode 100644 index 806657d..0000000 --- a/.specify/templates/checklist-template.md +++ /dev/null @@ -1,40 +0,0 @@ -# [CHECKLIST TYPE] Checklist: [FEATURE NAME] - -**Purpose**: [Brief description of what this checklist covers] -**Created**: [DATE] -**Feature**: [Link to spec.md or relevant documentation] - -**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. - - - -## [Category 1] - -- [ ] CHK001 First checklist item with clear action -- [ ] CHK002 Second checklist item -- [ ] CHK003 Third checklist item - -## [Category 2] - -- [ ] CHK004 Another category item -- [ ] CHK005 Item with specific criteria -- [ ] CHK006 Final item in this category - -## Notes - -- Check items off as completed: `[x]` -- Add comments or findings inline -- Link to relevant resources or documentation -- Items are numbered sequentially for easy reference diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md deleted file mode 100644 index 2336c15..0000000 --- a/.specify/templates/plan-template.md +++ /dev/null @@ -1,120 +0,0 @@ -# Implementation Plan: [FEATURE] - -**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] -**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` - -**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. - -## Summary - -[Extract from feature spec: primary requirement + technical approach from research] - -## Technical Context - - - -**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] -**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] -**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] -**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] -**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] -**Project Type**: [single/web/mobile - determines source structure] -**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] -**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] -**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -- **UI-Agnostic Core**: Does the proposed feature keep Domain/Application code free of any - concrete UI framework dependencies and use only `Modulus.UI.Abstractions` for UI contracts? -- **Dual-Engine Host Architecture**: Which hosts (Blazor, Avalonia) are targeted, and how will - UI assemblies be separated from core logic so the same module can run under multiple hosts? -- **Vertical Slice Modularity**: Which module(s) own this feature as a vertical slice, and can - they be loaded, tested, and versioned independently? -- **Pyramid Layering**: Do all dependencies follow - Presentation → UI Abstraction → Application → Domain → Infrastructure, with no cross-layer - shortcuts? -- **AI-Friendly Contracts & Plugin SDK**: What public contracts or SDK base types are required - or changed, and how will they remain strongly typed and self-describing for AI and human - authors? -- **Modern .NET & Technology Discipline**: Are the chosen runtime targets, MediatR usage, and - portability requirements consistent with the Modulus Constitution? - -Summarize any risks or intentional deviations here and link to the governance decision if -applicable. - -## Project Structure - -### Documentation (this feature) - -```text -specs/[###-feature]/ -├── plan.md # This file (/speckit.plan command output) -├── research.md # Phase 0 output (/speckit.plan command) -├── data-model.md # Phase 1 output (/speckit.plan command) -├── quickstart.md # Phase 1 output (/speckit.plan command) -├── contracts/ # Phase 1 output (/speckit.plan command) -└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) -``` - -### Source Code (repository root) - - -```text -# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) -src/ -├── models/ -├── services/ -├── cli/ -└── lib/ - -tests/ -├── contract/ -├── integration/ -└── unit/ - -# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) -backend/ -├── src/ -│ ├── models/ -│ ├── services/ -│ └── api/ -└── tests/ - -frontend/ -├── src/ -│ ├── components/ -│ ├── pages/ -│ └── services/ -└── tests/ - -# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) -api/ -└── [same as backend above] - -ios/ or android/ -└── [platform-specific structure: feature modules, UI flows, platform tests] -``` - -**Structure Decision**: [Document the selected structure and reference the real -directories captured above] - -## Complexity Tracking - -> **Fill ONLY if Constitution Check has violations that must be justified** - -| Violation | Why Needed | Simpler Alternative Rejected Because | -|-----------|------------|-------------------------------------| -| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | -| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md deleted file mode 100644 index b2ebf75..0000000 --- a/.specify/templates/spec-template.md +++ /dev/null @@ -1,149 +0,0 @@ -# Feature Specification: [FEATURE NAME] - -**Feature Branch**: `[###-feature-name]` -**Created**: [DATE] -**Status**: Draft -**Input**: User description: "$ARGUMENTS" - -## User Scenarios & Testing *(mandatory)* - - - -### User Story 1 - [Brief Title] (Priority: P1) - -[Describe this user journey in plain language] - -**Why this priority**: [Explain the value and why it has this priority level] - -**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] - -**Acceptance Scenarios**: - -1. **Given** [initial state], **When** [action], **Then** [expected outcome] -2. **Given** [initial state], **When** [action], **Then** [expected outcome] - ---- - -### User Story 2 - [Brief Title] (Priority: P2) - -[Describe this user journey in plain language] - -**Why this priority**: [Explain the value and why it has this priority level] - -**Independent Test**: [Describe how this can be tested independently] - -**Acceptance Scenarios**: - -1. **Given** [initial state], **When** [action], **Then** [expected outcome] - ---- - -### User Story 3 - [Brief Title] (Priority: P3) - -[Describe this user journey in plain language] - -**Why this priority**: [Explain the value and why it has this priority level] - -**Independent Test**: [Describe how this can be tested independently] - -**Acceptance Scenarios**: - -1. **Given** [initial state], **When** [action], **Then** [expected outcome] - ---- - -[Add more user stories as needed, each with an assigned priority] - -### Edge Cases - - - -- What happens when [boundary condition]? -- How does system handle [error scenario]? - -## Requirements *(mandatory)* - - - -### Functional Requirements - -- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] -- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] -- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] -- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] -- **FR-005**: System MUST [behavior, e.g., "log all security events"] - -*Example of marking unclear requirements:* - -- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] -- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] - -### Key Entities *(include if feature involves data)* - -- **[Entity 1]**: [What it represents, key attributes without implementation] -- **[Entity 2]**: [What it represents, relationships to other entities] - -## Success Criteria *(mandatory)* - - - -### Measurable Outcomes - -- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] -- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] -- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] -- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] - -## Architecture & Modulus Constraints *(mandatory for this repository)* - - - -### Module & Host Mapping - -- Owning module(s) (vertical slice): - - [e.g., Modulus.Modules.Logging, Modulus.Modules.Calculator] -- Target host(s): - - [Blazor, Avalonia, or both] -- UI assemblies involved (if any): - - [e.g., Module.UI.Blazor.dll, Module.UI.Avalonia.dll] - -### Layering & Dependencies - -- Layers touched by this feature: - - [Presentation, UI Abstraction, Application, Domain, Infrastructure] -- Any required cross-module communication (via MediatR or explicit interfaces): - - [Describe requests/notifications and handlers] -- Confirm there are no planned violations of the dependency pyramid: - - [If any, explain and link to governance decision] - -### Public Contracts & SDK Impact - -- New or changed public contracts / DTOs: - - [List request/response types and error models] -- New or updated SDK base types (if any): - - [e.g., new plugin base, new module base] -- Backward compatibility and migration notes: - - [How existing plugins/modules will be affected] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md deleted file mode 100644 index 401ce8c..0000000 --- a/.specify/templates/tasks-template.md +++ /dev/null @@ -1,257 +0,0 @@ ---- - -description: "Task list template for feature implementation" ---- - -# Tasks: [FEATURE NAME] - -**Input**: Design documents from `/specs/[###-feature-name]/` -**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ - -**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. - -**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) -- Include exact file paths in descriptions - -## Path Conventions - -- **Single project**: `src/`, `tests/` at repository root -- **Web app**: `backend/src/`, `frontend/src/` -- **Mobile**: `api/src/`, `ios/src/` or `android/src/` -- Paths shown below assume single project - adjust based on plan.md structure - - - -## Phase 1: Setup (Shared Infrastructure) - -**Purpose**: Project initialization and basic structure - -- [ ] T001 Create project structure per implementation plan -- [ ] T002 Initialize [language] project with [framework] dependencies -- [ ] T003 [P] Configure linting and formatting tools - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented - -**⚠️ CRITICAL**: No user story work can begin until this phase is complete - -Examples of foundational tasks (adjust based on your project): - -- [ ] T004 Setup database schema and migrations framework -- [ ] T005 [P] Implement authentication/authorization framework -- [ ] T006 [P] Setup API routing and middleware structure -- [ ] T007 Create base models/entities that all stories depend on -- [ ] T008 Configure error handling and logging infrastructure -- [ ] T009 Setup environment configuration management -- [ ] T00A Verify project references and namespaces follow the Modulus - Presentation → UI Abstraction → Application → Domain → Infrastructure dependency pyramid -- [ ] T00B Configure DI and MediatR scanning so modules and cross-module handlers - are discoverable without hard-coded wiring -- [ ] T00C Ensure Domain/Application projects do not reference concrete UI frameworks - and use only `Modulus.UI.Abstractions` for UI contracts - -**Checkpoint**: Foundation ready - user story implementation can now begin in parallel - ---- - -## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP - -**Goal**: [Brief description of what this story delivers] - -**Independent Test**: [How to verify this story works on its own] - -### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ - -> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** - -- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py -- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py - -### Implementation for User Story 1 - -- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py -- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py -- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) -- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py -- [ ] T016 [US1] Add validation and error handling -- [ ] T017 [US1] Add logging for user story 1 operations - -**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently - ---- - -## Phase 4: User Story 2 - [Title] (Priority: P2) - -**Goal**: [Brief description of what this story delivers] - -**Independent Test**: [How to verify this story works on its own] - -### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ - -- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py -- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py - -### Implementation for User Story 2 - -- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py -- [ ] T021 [US2] Implement [Service] in src/services/[service].py -- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py -- [ ] T023 [US2] Integrate with User Story 1 components (if needed) - -**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently - ---- - -## Phase 5: User Story 3 - [Title] (Priority: P3) - -**Goal**: [Brief description of what this story delivers] - -**Independent Test**: [How to verify this story works on its own] - -### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ - -- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py -- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py - -### Implementation for User Story 3 - -- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py -- [ ] T027 [US3] Implement [Service] in src/services/[service].py -- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py - -**Checkpoint**: All user stories should now be independently functional - ---- - -[Add more user story phases as needed, following the same pattern] - ---- - -## Phase N: Polish & Cross-Cutting Concerns - -**Purpose**: Improvements that affect multiple user stories - -- [ ] TXXX [P] Documentation updates in docs/ -- [ ] TXXX Code cleanup and refactoring -- [ ] TXXX Performance optimization across all stories -- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ -- [ ] TXXX Security hardening -- [ ] TXXX Run quickstart.md validation - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies - can start immediately -- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories -- **User Stories (Phase 3+)**: All depend on Foundational phase completion - - User stories can then proceed in parallel (if staffed) - - Or sequentially in priority order (P1 → P2 → P3) -- **Polish (Final Phase)**: Depends on all desired user stories being complete - -### User Story Dependencies - -- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories -- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable -- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable - -### Within Each User Story - -- Tests (if included) MUST be written and FAIL before implementation -- Models before services -- Services before endpoints -- Core implementation before integration -- Story complete before moving to next priority - -### Parallel Opportunities - -- All Setup tasks marked [P] can run in parallel -- All Foundational tasks marked [P] can run in parallel (within Phase 2) -- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) -- All tests for a user story marked [P] can run in parallel -- Models within a story marked [P] can run in parallel -- Different user stories can be worked on in parallel by different team members - ---- - -## Parallel Example: User Story 1 - -```bash -# Launch all tests for User Story 1 together (if tests requested): -Task: "Contract test for [endpoint] in tests/contract/test_[name].py" -Task: "Integration test for [user journey] in tests/integration/test_[name].py" - -# Launch all models for User Story 1 together: -Task: "Create [Entity1] model in src/models/[entity1].py" -Task: "Create [Entity2] model in src/models/[entity2].py" -``` - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. Complete Phase 1: Setup -2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) -3. Complete Phase 3: User Story 1 -4. **STOP and VALIDATE**: Test User Story 1 independently -5. Deploy/demo if ready - -### Incremental Delivery - -1. Complete Setup + Foundational → Foundation ready -2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) -3. Add User Story 2 → Test independently → Deploy/Demo -4. Add User Story 3 → Test independently → Deploy/Demo -5. Each story adds value without breaking previous stories - -### Parallel Team Strategy - -With multiple developers: - -1. Team completes Setup + Foundational together -2. Once Foundational is done: - - Developer A: User Story 1 - - Developer B: User Story 2 - - Developer C: User Story 3 -3. Stories complete and integrate independently - ---- - -## Notes - -- [P] tasks = different files, no dependencies -- [Story] label maps task to specific user story for traceability -- Each user story should be independently completable and testable -- Verify tests fail before implementing -- Commit after each task or logical group -- Stop at any checkpoint to validate story independently -- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/Modulus.sln b/Modulus.sln index 84611f6..10bd18e 100644 --- a/Modulus.sln +++ b/Modulus.sln @@ -65,6 +65,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modulus.UI.Blazor", "src\Mo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modulus.UI.Avalonia.Tests", "tests\Modulus.UI.Avalonia.Tests\Modulus.UI.Avalonia.Tests.csproj", "{6D47D1B4-A5E3-4BD6-B857-7845F59E4076}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modulus.Infrastructure.Data", "src\Shared\Modulus.Infrastructure.Data\Modulus.Infrastructure.Data.csproj", "{27F8D10F-EB5E-4903-8FBA-07457612C088}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -350,6 +352,18 @@ Global {6D47D1B4-A5E3-4BD6-B857-7845F59E4076}.Release|x64.Build.0 = Release|Any CPU {6D47D1B4-A5E3-4BD6-B857-7845F59E4076}.Release|x86.ActiveCfg = Release|Any CPU {6D47D1B4-A5E3-4BD6-B857-7845F59E4076}.Release|x86.Build.0 = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|Any CPU.Build.0 = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|x64.ActiveCfg = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|x64.Build.0 = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|x86.ActiveCfg = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Debug|x86.Build.0 = Debug|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|Any CPU.ActiveCfg = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|Any CPU.Build.0 = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|x64.ActiveCfg = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|x64.Build.0 = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|x86.ActiveCfg = Release|Any CPU + {27F8D10F-EB5E-4903-8FBA-07457612C088}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -383,5 +397,6 @@ Global {E05CB311-CACE-451C-9682-EC03547B1EE3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {07C30195-C5FD-4B14-86AB-3CE59E5C9CF8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {6D47D1B4-A5E3-4BD6-B857-7845F59E4076} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {27F8D10F-EB5E-4903-8FBA-07457612C088} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C} EndGlobalSection EndGlobal diff --git a/build/BuildTasks.cs b/build/BuildTasks.cs index 2d1628d..eaf500e 100644 --- a/build/BuildTasks.cs +++ b/build/BuildTasks.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Collections.Generic; +using System.Text.Json; using Serilog; using Serilog.Sinks.SystemConsole.Themes; using Nuke.Common; @@ -26,7 +27,7 @@ public static int Main() .WriteTo.Console(theme: AnsiConsoleTheme.Code) .CreateLogger(); - return Execute(x => x.BuildAll); + return Execute(x => x.Compile); } // Custom log helpers with ANSI color codes for consistent coloring across platforms @@ -51,12 +52,20 @@ private static void LogHeader(string message) [Parameter("Name of the plugin to pack (required when op=single)", Name = "name")] readonly string PluginName; + + [Parameter("Target host to build for: 'avalonia' (default), 'blazor', or 'all'", Name = "target-host")] + readonly string TargetHost = "avalonia"; [Solution] readonly Solution Solution; AbsolutePath ArtifactsDirectory => RootDirectory / "artifacts"; AbsolutePath PluginsArtifactsDirectory => ArtifactsDirectory / "plugins"; AbsolutePath SamplesDirectory => RootDirectory / "src" / "samples"; + AbsolutePath ModulesDirectory => RootDirectory / "src" / "Modules"; + + // Host project names + const string AvaloniaHostProject = "Modulus.Host.Avalonia"; + const string BlazorHostProject = "Modulus.Host.Blazor"; Target Clean => _ => _ .Executes(() => @@ -78,18 +87,8 @@ private static void LogHeader(string message) .SetProjectFile(Solution)); }); - Target Build => _ => _ - .DependsOn(Restore) - .Executes(() => - { - DotNetTasks.DotNetBuild(s => s - .SetProjectFile(Solution) - .SetConfiguration(Configuration) - .EnableNoRestore()); - }); - Target Pack => _ => _ - .DependsOn(Build) + .DependsOn(Compile) .Executes(() => { foreach (var project in Solution.AllProjects.Where(p => p.Name.Contains("Plugin") || p.Name.Contains("App"))) @@ -102,19 +101,50 @@ private static void LogHeader(string message) } }); + /// + /// Build all and run the host application + /// Usage: nuke run [--target-host avalonia|blazor] + /// Target Run => _ => _ + .DependsOn(Build) + .Description("Build all and run the host application") .Executes(() => { - var desktopProject = Solution.AllProjects.FirstOrDefault(p => p.Name == "Modulus.Host.Avalonia"); - if (desktopProject == null) - throw new Exception("Modulus.Host.Avalonia project not found"); - DotNetTasks.DotNetRun(s => s - .SetProjectFile(desktopProject) - .SetConfiguration(Configuration)); + var hostProjectName = TargetHost?.ToLower() == "blazor" ? BlazorHostProject : AvaloniaHostProject; + var outputDir = ArtifactsDirectory / hostProjectName; + + var executable = outputDir / hostProjectName; + if (OperatingSystem.IsWindows()) + executable = outputDir / $"{hostProjectName}.exe"; + + if (!File.Exists(executable)) + { + LogError($"Executable not found: {executable}"); + LogError("Run 'nuke build' first to build the application."); + throw new Exception($"Executable not found: {executable}"); + } + + LogHeader($"Running {hostProjectName}"); + LogHighlight($"Executable: {executable}"); + + // Run the application + var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = executable, + WorkingDirectory = outputDir, + UseShellExecute = false + }); + + if (process != null) + { + LogSuccess($"Started {hostProjectName} (PID: {process.Id})"); + process.WaitForExit(); + LogNormal($"Application exited with code: {process.ExitCode}"); + } }); Target Test => _ => _ - .DependsOn(Build) + .DependsOn(Compile) .Executes(() => { DotNetTasks.DotNetTest(s => s @@ -124,10 +154,180 @@ private static void LogHeader(string message) }); Target BuildAll => _ => _ - .DependsOn(Build, Test); + .DependsOn(Compile, Test); Target Default => _ => _ - .DependsOn(Build); + .DependsOn(Compile); + + // ============================================================ + // Application Build Targets + // ============================================================ + + /// + /// Just compile the solution (no publish/package) + /// + Target Compile => _ => _ + .DependsOn(Restore) + .Description("Compile the solution") + .Executes(() => + { + DotNetTasks.DotNetBuild(s => s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .EnableNoRestore()); + }); + + /// + /// Build and publish host application to artifacts/ + /// Usage: nuke build-app [--target-host avalonia|blazor|all] + /// + Target BuildApp => _ => _ + .DependsOn(Restore) + .Description("Build and publish host application to artifacts/") + .Executes(() => + { + var hostProjects = GetTargetHostProjects(); + + foreach (var hostProjectName in hostProjects) + { + var hostProject = Solution.AllProjects.FirstOrDefault(p => p.Name == hostProjectName); + if (hostProject == null) + { + LogError($"Host project not found: {hostProjectName}"); + continue; + } + + var outputDir = ArtifactsDirectory / hostProjectName; + + LogHeader($"Building {hostProjectName}"); + + DotNetTasks.DotNetPublish(s => s + .SetProject(hostProject) + .SetConfiguration(Configuration) + .SetOutput(outputDir) + .EnableNoRestore()); + + LogSuccess($"Published {hostProjectName} to {outputDir}"); + } + }); + + /// + /// Build and package modules to artifacts/{Host}/Modules/ + /// Usage: nuke build-module [--target-host avalonia|blazor|all] [--name ModuleName] + /// + Target BuildModule => _ => _ + .DependsOn(Restore) + .Description("Build and package modules to artifacts/{Host}/Modules/") + .Executes(() => + { + var hostProjects = GetTargetHostProjects(); + + // Get module directories to build + var moduleDirectories = string.IsNullOrEmpty(PluginName) + ? Directory.GetDirectories(ModulesDirectory).Select(d => (AbsolutePath)d).ToArray() + : new[] { ModulesDirectory / PluginName }; + + foreach (var moduleDir in moduleDirectories) + { + if (!Directory.Exists(moduleDir)) + { + LogWarning($"Module directory not found: {moduleDir}"); + continue; + } + + var moduleName = Path.GetFileName(moduleDir); + var manifestPath = Path.Combine(moduleDir, "manifest.json"); + + if (!File.Exists(manifestPath)) + { + LogWarning($"No manifest.json in {moduleName}, skipping"); + continue; + } + + LogHeader($"Building Module: {moduleName}"); + + // Build all projects in this module + var moduleProjects = Directory.GetFiles(moduleDir, "*.csproj", SearchOption.AllDirectories); + foreach (var projectPath in moduleProjects) + { + DotNetTasks.DotNetBuild(s => s + .SetProjectFile(projectPath) + .SetConfiguration(Configuration) + .EnableNoRestore()); + } + + // Package to each target host's Modules directory + foreach (var hostProjectName in hostProjects) + { + var hostType = hostProjectName.Contains("Avalonia") ? "Avalonia" : "Blazor"; + var moduleOutputDir = ArtifactsDirectory / hostProjectName / "Modules" / moduleName; + + // Clean and create output directory + if (Directory.Exists(moduleOutputDir)) + Directory.Delete(moduleOutputDir, true); + Directory.CreateDirectory(moduleOutputDir); + + // Copy manifest.json + File.Copy(manifestPath, moduleOutputDir / "manifest.json"); + + // Copy DLLs from each project's output + foreach (var projectPath in moduleProjects) + { + var projectDir = Path.GetDirectoryName(projectPath); + var projectName = Path.GetFileNameWithoutExtension(projectPath); + + // Skip UI projects that don't match the host type + if (projectName.Contains(".UI.")) + { + var isAvaloniaUi = projectName.Contains(".UI.Avalonia"); + var isBlazorUi = projectName.Contains(".UI.Blazor"); + + if (isAvaloniaUi && hostType != "Avalonia") continue; + if (isBlazorUi && hostType != "Blazor") continue; + } + + // Find the output directory + var binDir = Path.Combine(projectDir, "bin", Configuration.ToString()); + if (!Directory.Exists(binDir)) continue; + + // Find the target framework folder + var tfmDirs = Directory.GetDirectories(binDir); + var tfmDir = tfmDirs.FirstOrDefault(d => d.Contains("net")); + if (tfmDir == null) continue; + + // Copy DLL and PDB + var dllPath = Path.Combine(tfmDir, $"{projectName}.dll"); + var pdbPath = Path.Combine(tfmDir, $"{projectName}.pdb"); + + if (File.Exists(dllPath)) + File.Copy(dllPath, moduleOutputDir / $"{projectName}.dll", true); + if (File.Exists(pdbPath)) + File.Copy(pdbPath, moduleOutputDir / $"{projectName}.pdb", true); + } + + LogSuccess($" → {hostType}: {moduleOutputDir}"); + } + } + }); + + /// + /// Full build: host application + all modules + /// Usage: nuke build [--target-host avalonia|blazor|all] + /// + Target Build => _ => _ + .DependsOn(BuildApp, BuildModule) + .Description("Full build: host application + all modules"); + + // Helper to get target host projects based on --target-host parameter + private string[] GetTargetHostProjects() + { + return TargetHost?.ToLower() switch + { + "blazor" => new[] { BlazorHostProject }, + "all" => new[] { AvaloniaHostProject, BlazorHostProject }, + _ => new[] { AvaloniaHostProject } // default to avalonia + }; + } Target CleanPluginsArtifacts => _ => _ .Executes(() => diff --git a/openspec/changes/database-driven-modules/design.md b/openspec/changes/database-driven-modules/design.md new file mode 100644 index 0000000..951ce51 --- /dev/null +++ b/openspec/changes/database-driven-modules/design.md @@ -0,0 +1,70 @@ +# Design: Database-Driven Plugin System (Module / Component Split) + +## 1) Terminology & Layers +- **ModulusModule** (模块,交付单元) + - 用户安装/卸载/启用/禁用的顶层对象。 + - 映射:数据库 `Sys_Modules` 记录;文件夹 `App_Data/Modules/{Id}`;`manifest.json`。 + - 边界:版本、权限、隔离(ALC)、可选独立存储/配置。 +- **ModulusComponent** (组件,代码单元) + - C# 类:`public class XxxComponent : ModulusComponent`。 + - 依赖:`[DependsOn]`(可跨模块)。 + - 职责:注册服务、声明菜单/扩展点、参与生命周期。 + - 一个模块可包含多个组件;模块通过 `manifest.entryComponent` 指定入口组件。 +- **MenuEntry** 仍投影到 DB (`Sys_Menus`),来源于组件属性或 manifest。 + +## 2) Data Model (SQLite) + +### `ModuleEntity` (`Sys_Modules`) +| Property | Type | Description | +|---|---|---| +| `Id` | PK, String | `manifest.id` (ModuleId) | +| `Name` | String | Display name | +| `Version` | String | Installed version | +| `Author` | String | Author | +| `Website` | String | Website | +| `Path` | String | Relative path to `manifest.json` | +| `EntryComponent` | String | FQCN of entry component | +| `IsSystem` | Bool | Managed by seeder; not uninstallable | +| `IsEnabled` | Bool | User preference | +| `State` | Enum | `Ready`, `MissingFiles`, `Incompatible` | + +### `MenuEntity` (`Sys_Menus`) +| Property | Type | Description | +|---|---|---| +| `Id` | PK, String | Menu id | +| `ModuleId` | FK | Module owner | +| `ParentId` | String? | Nesting | +| `DisplayName`| String | | +| `Icon` | String | | +| `Route` | String | | +| `Order` | Int | | + +## 3) Runtime Workflow (Hybrid Driven) + +### A. Startup (Seeding + Integrity) +1. EF Core migrate. +2. System seeding: ensure required modules exist/updated. +3. Integrity: mark `MissingFiles` if manifest missing. +4. **Module load (physical)**: for each enabled `Ready` module, create ALC and load assemblies. +5. **Component resolution (logical)**: scan assemblies for `ModulusComponent`, build dependency graph via `[DependsOn]`, topo-init (ConfigureServices → Init). + +### B. Install/Update (Projection) +1. Extract to `Modules/{Id}`. +2. Read manifest (includes `entryComponent`). +3. Isolated scan to find `ModulusComponent` + `[Menu]`. +4. Upsert `ModuleEntity`; replace menus in `Sys_Menus`. +5. (Optional) cache component list for diagnostics. + +### C. Menu Rendering +- UI reads `Sys_Menus` (join enabled modules). Zero reflection at render time. + +## 4) Developer Experience +- **Authoring**: build a Module (package) with one or more Components; mark entry via manifest; use `[DependsOn]`, `[Menu]`, future `[ExtensionPoint]`. +- **Testing**: import local module (folder/manifest) → projection pipeline. +- **AI-friendly**: explicit component dependencies; declarative module metadata. + +## 5) Technology +- EF Core Sqlite for state. +- ALC per module (default isolation). +- Markdown README for detail page (fallback to manifest description). + diff --git a/openspec/changes/database-driven-modules/proposal.md b/openspec/changes/database-driven-modules/proposal.md new file mode 100644 index 0000000..3058e6f --- /dev/null +++ b/openspec/changes/database-driven-modules/proposal.md @@ -0,0 +1,32 @@ +# Database-Driven Module Lifecycle & Menu System + +## Why +Currently, Modulus relies on filesystem scanning to find and load modules. This has several limitations: +1. **Duplicate Menus**: Inconsistent module IDs between loading and menu registration. +2. **Performance**: Scanning assemblies and parsing attributes at every startup is slow. +3. **Fragility**: No persistent state to track user preferences (enabled/disabled) or handle missing files gracefully. +4. **Versioning**: Hard to manage system module updates vs. user modules. + +## What +We will transition from a **Runtime-Scanning** architecture to a **State-Driven** architecture backed by a local database (SQLite + EF Core). + +### Key Changes +1. **Persistence Layer**: Introduce `Sys_Modules` and `Sys_Menus` tables. +2. **Seeding & Migration**: Auto-register system modules on startup/update. +3. **Install/Update Pipeline**: + * **Install**: Extract -> Load (Temp Context) -> Scan Attributes -> Write DB (Modules & Menus). + * **Runtime**: Read DB -> Load Assemblies (No Scanning). +4. **Menu System**: + * **Write-Time**: Parse `[Menu]` attributes during installation. + * **Read-Time**: Query `Sys_Menus` to render UI. +5. **Self-Healing**: Detect missing files on startup, mark as "Warning", allow clean removal. + +## Impact +* **Performance**: Faster startup (menu rendering doesn't wait for modules). +* **Stability**: Zero duplicate menus; atomic installation. +* **UX**: Users can manage (enable/disable/remove) modules; "Yellow Warning" for broken ones. +* **Dev**: Keep using convenient `[Menu]` attributes. + +## Migration +Existing modules will need to be "imported" into the database on the first run of the new version. + diff --git a/openspec/changes/database-driven-modules/specs/runtime/spec.md b/openspec/changes/database-driven-modules/specs/runtime/spec.md new file mode 100644 index 0000000..a5948fd --- /dev/null +++ b/openspec/changes/database-driven-modules/specs/runtime/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Hybrid Module/Component Runtime +The runtime SHALL load modules (ModulusModule) as deployable units and resolve components (ModulusComponent) as code units with dependency ordering. + +#### Scenario: Module load succeeds +- **WHEN** a module is enabled and its manifest exists +- **THEN** the runtime loads its assemblies in an isolated ALC (by default) +- **AND** discovers all `ModulusComponent` types +- **AND** builds a dependency graph from `[DependsOn]` (including cross-module dependencies) +- **AND** initializes components in topological order (`ConfigureServices` then `OnApplicationInitialization`). + +#### Scenario: Missing files are flagged +- **WHEN** a module manifest file is missing at startup +- **THEN** the module state is set to `MissingFiles` +- **AND** the module is skipped from the load list. + +### Requirement: Menu Projection +Menu entries SHALL be projected to the database at install/update time and read from the database at render time. + +#### Scenario: Install or update module +- **WHEN** a module is installed or updated +- **THEN** the installer scans components for `[Menu]` (host-aware) +- **AND** writes menu rows with ids like `{ModuleId}.{MenuId}` into `Sys_Menus` +- **AND** replaces any existing menus for that module (bulk upsert). + +#### Scenario: Render menus +- **WHEN** the shell renders navigation +- **THEN** it queries `Sys_Menus` joined with enabled modules +- **AND** does not require reflection at render time. + +### Requirement: Detail Content Fallback +Module detail pages SHALL prefer README content and fall back to manifest description. + +#### Scenario: README available +- **WHEN** `README.md` exists in the module folder +- **THEN** its Markdown content is used for the detail view. + +#### Scenario: README missing +- **WHEN** no `README.md` exists +- **AND** `manifest.description` is present +- **THEN** the manifest description is shown. + +#### Scenario: No content available +- **WHEN** neither README nor manifest description exists +- **THEN** show "No description provided." + +### Requirement: Module State Management +Module state SHALL be persisted and enforced at startup. + +#### Scenario: Startup integrity +- **WHEN** the application starts +- **THEN** the system migrates the DB +- **AND** seeds system modules (install/upgrade if missing or outdated) +- **AND** marks missing manifests as `MissingFiles` +- **AND** only modules with `State=Ready` and `IsEnabled=true` are loaded. + diff --git a/openspec/changes/database-driven-modules/tasks.md b/openspec/changes/database-driven-modules/tasks.md new file mode 100644 index 0000000..e189e3b --- /dev/null +++ b/openspec/changes/database-driven-modules/tasks.md @@ -0,0 +1,33 @@ +# Tasks + +- [x] **Infrastructure Setup** + - [x] Add `Modulus.Infrastructure.Data` project (Class Library). + - [x] Add packages: `Microsoft.EntityFrameworkCore.Sqlite`, `Microsoft.EntityFrameworkCore.Design`. + - [x] Define `ModulusDbContext`, `ModuleEntity`, `MenuEntity`. + - [x] Create initial EF Migration. + +- [x] **Core Logic Refactor** + - [x] Implement `IModuleRepository` and `IMenuRepository`. + - [x] Create `ModuleInstallerService`: + - [x] Logic to Scan Assembly Attributes -> MenuEntities. + - [x] Logic to Write Manifest -> ModuleEntity. + - [x] Create `SystemModuleSeeder`: + - [x] List of built-in modules. + - [x] Logic to trigger `ModuleInstallerService` on startup. + +- [x] **Runtime Integration** + - [x] Update `ModulusApplication`: + - [x] Remove directory scanner. + - [x] Call `SystemModuleSeeder.EnsureSeededAsync()`. + - [x] Call `IntegrityChecker.CheckAsync()`. + - [x] Update `ModuleLoader`: + - [x] Accept `List` instead of scanning paths (via `ModulusApplicationFactory` logic). + - [x] Update `ShellViewModel` (Avalonia/Blazor): + - [x] Load menus from `IMenuRepository` (Via `ModulusApplication` pushing to `IMenuRegistry` which Shell consumes). + +- [x] **UI Updates** + - [x] Update Module Management Page: + - [x] Show list from DB. + - [x] Handle "Yellow Warning" state (Missing Files). + - [x] Implement "Remove" (Delete DB record + Clean folder). + - [x] Add "Import Module" button (for Devs). diff --git a/openspec/changes/update-runtime-module-lifecycle/design.md b/openspec/changes/update-runtime-module-lifecycle/design.md new file mode 100644 index 0000000..b678bfe --- /dev/null +++ b/openspec/changes/update-runtime-module-lifecycle/design.md @@ -0,0 +1,25 @@ +## Context +- Runtime hot-load/unload must execute module lifecycle and integrate UI/service registrations safely. +- Manifest validation needs host/dependency/semver/integrity checks. +- Dependency ordering must merge manifest deps with DependsOn and detect cycles/missing modules. +- Shared assembly allowlist should follow assembly-domain metadata instead of hardcoded names. + +## Decisions +- Create module-scoped ServiceCollection/ServiceProvider per runtime load; run Pre/Configure/PostConfigureServices then OnApplicationInitializationAsync; register menus/views immediately; store handle for cleanup. +- On unload, invoke OnApplicationShutdownAsync, unregister menus/navigation, dispose module provider/scope, remove module manager/context entry, then unload ALC; guard system modules. +- Extend manifest validator: required fields, supportedHosts match host, semver parse + VersionRange validation, dependency existence, optional assembly hashes + signature. +- Build unified dependency graph combining manifest deps (id+version) and DependsOn attributes; topological sort with cycle/missing diagnostics, surface errors to UI/logs. +- Shared allowlist sourced from AssemblyDomainInfo metadata over default context assemblies plus optional config; ModuleLoadContext consults catalog and logs diagnostics when Module-domain assemblies are requested as shared. + +## Risks / Trade-offs +- Additional DI scopes per module increase memory; mitigated by disposal on unload. +- Semver dependency validation adds NuGet.Versioning dependency; chosen for correctness over bespoke parsing. +- Menu/navigation deregistration requires registry changes; ensure backward compatibility with additive remove APIs. + +## Migration Plan +- Add shared allowlist catalog and manifest validation enhancements. +- Update loader/unloader to use module handles and dependency resolver. +- Update navigation/menu registries to support deregistration. +- Run tests covering load/unload, validation, and dependency ordering. + + diff --git a/openspec/changes/update-runtime-module-lifecycle/proposal.md b/openspec/changes/update-runtime-module-lifecycle/proposal.md new file mode 100644 index 0000000..46e929c --- /dev/null +++ b/openspec/changes/update-runtime-module-lifecycle/proposal.md @@ -0,0 +1,19 @@ +# Change: Update runtime module lifecycle & validation + +## Why +- Runtime-loaded modules skip DI/lifecycle and leave stale UI/service registrations. +- Manifest validation misses host compatibility, dependency semantics, and integrity checks. +- Dependency ordering ignores manifest deps and cycle/missing detection. +- Shared assembly allowlist is hardcoded, risking duplicate loads and type mismatches. + +## What Changes +- Add module-scoped DI + lifecycle execution (Pre/Configure/Post + init/shutdown) with tracked handles and cleanup on unload. +- Strengthen manifest validation for required fields, supported hosts, semver dependencies, hashes, and signature. +- Build unified dependency graph (manifest deps + DependsOn) with topo sort and diagnostics. +- Drive shared assembly allowlist from assembly-domain metadata with runtime diagnostics. + +## Impact +- Affected specs: runtime +- Affected code: Modulus.Core runtime loader/manager, manifest validation, load context, host menu/navigation integration + + diff --git a/openspec/changes/update-runtime-module-lifecycle/specs/runtime/spec.md b/openspec/changes/update-runtime-module-lifecycle/specs/runtime/spec.md new file mode 100644 index 0000000..b7863d6 --- /dev/null +++ b/openspec/changes/update-runtime-module-lifecycle/specs/runtime/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Runtime module lifecycle and cleanup +Runtime-loaded modules MUST execute full lifecycle with module-scoped DI and clean teardown. + +#### Scenario: Load executes lifecycle with module services +- **WHEN** a module is loaded at runtime with a valid manifest and supported host +- **THEN** the loader builds a module ServiceCollection/ServiceProvider +- **AND** instantiates all `IModule` types in the package +- **AND** runs Pre/Configure/PostConfigureServices followed by OnApplicationInitializationAsync +- **AND** registers module UI/menu contributions and marks the module active + +#### Scenario: Unload calls shutdown and removes registrations +- **WHEN** a loaded module is unloaded +- **THEN** OnApplicationShutdownAsync is invoked in reverse order +- **AND** module menus/navigation/views/services registered during load are deregistered +- **AND** the module ServiceProvider is disposed and its AssemblyLoadContext is unloaded + +#### Scenario: System modules protected from unload +- **WHEN** an unload is requested for a system module +- **THEN** the operation is rejected with a clear error + +### Requirement: Manifest validation for host, deps, and integrity +Manifests MUST be validated for required fields, host compatibility, dependency semantics, and integrity. + +#### Scenario: Unsupported host is rejected +- **WHEN** the manifest SupportedHosts does not include the current host +- **THEN** loading fails with a diagnostic explaining the host mismatch + +#### Scenario: Dependency or version constraint failure blocks load +- **WHEN** a declared dependency is missing or its version does not satisfy the SemVer range +- **THEN** loading fails with a diagnostic naming the missing/invalid dependency + +#### Scenario: Integrity checks enforced when provided +- **WHEN** manifest or assembly hashes/signatures are present +- **THEN** the loader verifies them and rejects the module on mismatch + +### Requirement: Unified dependency graph with topo ordering +Module initialization order MUST use a unified dependency graph from manifest Dependencies and DependsOn attributes, with validation. + +#### Scenario: Missing or cyclic dependency fails fast +- **WHEN** the graph contains a missing module or a cycle +- **THEN** loading/initialization is blocked and the error identifies the offending modules + +#### Scenario: Modules initialize in dependency order +- **WHEN** modules have dependencies +- **THEN** initialization runs in topological order honoring both manifest and DependsOn links + +### Requirement: Shared assembly resolution from domain metadata +Shared assembly allowlist MUST come from assembly-domain metadata/config, with diagnostics for mismatches. + +#### Scenario: Shared assemblies resolved via domain metadata +- **WHEN** a module requests an assembly marked Shared by assembly-domain metadata +- **THEN** it is resolved from the host shared context instead of a private copy + +#### Scenario: Misdeclared shared/module assembly surfaces diagnostics +- **WHEN** a Module-domain assembly is requested from the shared list or vice versa +- **THEN** the loader emits a diagnostic to help correct the domain assignment + + diff --git a/openspec/changes/update-runtime-module-lifecycle/tasks.md b/openspec/changes/update-runtime-module-lifecycle/tasks.md new file mode 100644 index 0000000..cfbc172 --- /dev/null +++ b/openspec/changes/update-runtime-module-lifecycle/tasks.md @@ -0,0 +1,8 @@ +## 1. Implementation +- [ ] 1.1 Implement module-scoped DI + lifecycle execution on runtime load +- [ ] 1.2 Add unload cleanup (shutdown hooks, menu/view/service deregistration, dispose providers) +- [ ] 1.3 Strengthen manifest validation (fields, host match, semver deps, hashes/signature) +- [ ] 1.4 Build unified dependency graph (manifest deps + DependsOn) with cycle/missing detection +- [ ] 1.5 Drive shared assembly allowlist from assembly domain metadata with diagnostics + + diff --git a/scripts/deploy-module.ps1 b/scripts/deploy-module.ps1 deleted file mode 100644 index d818b49..0000000 --- a/scripts/deploy-module.ps1 +++ /dev/null @@ -1,42 +0,0 @@ -param( - [string]$ModuleId = "SimpleNotes", - [string]$Configuration = "Debug" -) - -$RootDir = Resolve-Path "$PSScriptRoot/.." -$ModulesOutDir = "$RootDir/_output/modules/$ModuleId" - -Write-Host "Deploying $ModuleId to $ModulesOutDir..." - -# Clean -if (Test-Path $ModulesOutDir) { - Remove-Item $ModulesOutDir -Recurse -Force -} -New-Item -ItemType Directory -Path $ModulesOutDir -Force | Out-Null - -# Copy Manifest -Copy-Item "$RootDir/src/Modules/$ModuleId/manifest.json" "$ModulesOutDir/" - -# Build and Copy Core -$CoreProject = "$RootDir/src/Modules/$ModuleId/$ModuleId.Core/$ModuleId.Core.csproj" -dotnet publish $CoreProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -# Build and Copy UI.Blazor -$BlazorProject = "$RootDir/src/Modules/$ModuleId/$ModuleId.UI.Blazor/$ModuleId.UI.Blazor.csproj" -dotnet publish $BlazorProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -# Build and Copy UI.Avalonia -$AvaloniaProject = "$RootDir/src/Modules/$ModuleId/$ModuleId.UI.Avalonia/$ModuleId.UI.Avalonia.csproj" -dotnet publish $AvaloniaProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -# Cleanup unwanted files (pdb, etc if needed, or duplicates) -# dotnet publish will copy dependencies. We might have duplicates or shared libs. -# For now, simplistic flat folder. -# NOTE: In real scenario, we might want subfolders or ALC handling of shared deps. -# Modulus Core expects flat structure currently? -# ModuleLoadContext looks in `_basePath`. -# ModuleLoader: `foreach (var assemblyRelativePath in manifest.CoreAssemblies) ... alc.LoadFromAssemblyPath(Path.Combine(packagePath, assemblyRelativePath));` -# So flat is fine if manifest says "SimpleNotes.Core.dll". - -Write-Host "Deployment Complete." - diff --git a/scripts/deploy-shell.ps1 b/scripts/deploy-shell.ps1 deleted file mode 100644 index 7be45a0..0000000 --- a/scripts/deploy-shell.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -param( - [string]$ModuleId = "Modulus.Shell", - [string]$Configuration = "Debug" -) - -$RootDir = Resolve-Path "$PSScriptRoot/.." -$ModulesOutDir = "$RootDir/_output/modules/$ModuleId" - -Write-Host "Deploying $ModuleId to $ModulesOutDir..." - -if (Test-Path $ModulesOutDir) { - Remove-Item $ModulesOutDir -Recurse -Force -} -New-Item -ItemType Directory -Path $ModulesOutDir -Force | Out-Null - -Copy-Item "$RootDir/src/Modules/Shell/manifest.json" "$ModulesOutDir/" - -# Core -# Project name mismatch: folder is Shell.Core but csproj is Shell.Core.csproj? -# I created it as Shell.Core/Shell.Core.csproj -$CoreProject = "$RootDir/src/Modules/Shell/Shell.Core/Shell.Core.csproj" -dotnet publish $CoreProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -# Blazor -$BlazorProject = "$RootDir/src/Modules/Shell/Shell.UI.Blazor/Shell.UI.Blazor.csproj" -dotnet publish $BlazorProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -# Avalonia -$AvaloniaProject = "$RootDir/src/Modules/Shell/Shell.UI.Avalonia/Shell.UI.Avalonia.csproj" -dotnet publish $AvaloniaProject -c $Configuration -o "$ModulesOutDir" --no-self-contained - -Write-Host "Deployment Complete." - diff --git a/specs/001-core-architecture/contracts/runtime-contracts.md b/specs/001-core-architecture/contracts/runtime-contracts.md deleted file mode 100644 index 27ecea4..0000000 --- a/specs/001-core-architecture/contracts/runtime-contracts.md +++ /dev/null @@ -1,65 +0,0 @@ -# Runtime Contracts & Interfaces - -## Module System - -### IModule -所有模块必须实现的接口。推荐继承 `Modulus.Sdk.ModuleBase`。 - -```csharp -public interface IModule -{ - void PreConfigureServices(IModuleLifecycleContext context); - void ConfigureServices(IModuleLifecycleContext context); - void PostConfigureServices(IModuleLifecycleContext context); - - Task OnApplicationInitializationAsync(IModuleInitializationContext context, CancellationToken cancellationToken = default); - Task OnApplicationShutdownAsync(IModuleInitializationContext context, CancellationToken cancellationToken = default); -} -``` - -### IModuleProvider -定义模块发现策略。 - -```csharp -public interface IModuleProvider -{ - Task> GetModulePackagesAsync(CancellationToken cancellationToken = default); - bool IsSystemSource { get; } -} -``` - -### IModuleLoader -负责加载、卸载和重载模块。 - -```csharp -public interface IModuleLoader -{ - Task LoadAsync(string packagePath, bool isSystem = false, CancellationToken cancellationToken = default); - Task UnloadAsync(string moduleId); - Task ReloadAsync(string moduleId, CancellationToken cancellationToken = default); - Task GetDescriptorAsync(string packagePath, CancellationToken cancellationToken = default); -} -``` - -## Module Manifest -`manifest.json` 结构定义 (see `Modulus.Sdk.ModuleManifest`). - -```json -{ - "id": "string", - "version": "string", - "displayName": "string?", - "description": "string?", - "supportedHosts": ["string"], - "coreAssemblies": ["string"], - "uiAssemblies": { - "HostType": ["string"] - }, - "dependencies": { - "ModuleId": "Version" - } -} -``` - -## SDK Helpers -`Modulus.Sdk.PluginPackageBuilder` 用于辅助构建插件包结构和清单。 diff --git a/specs/001-core-architecture/data-model.md b/specs/001-core-architecture/data-model.md deleted file mode 100644 index d385393..0000000 --- a/specs/001-core-architecture/data-model.md +++ /dev/null @@ -1,144 +0,0 @@ -# Data Model: Modulus 核心架构与双宿主运行时 - -**Feature**: `001-core-architecture` -**Source Spec**: `specs/001-core-architecture/spec.md` -**Last Updated**: 2025-12-03 - -本文件从规格中提取核心概念实体,描述它们的职责、关键字段与关系,供后续实现与 SDK 设计参考。 - ---- - -## 1. Module(模块) - -**职责**: 表示一个垂直切片功能单元,可包含 Domain / Application / Infrastructure 以及可选的 -Presentation / UI 实现。 - -**关键属性(示意字段名,仅为设计参考)**: - -- `ModuleId`:模块唯一标识(**推荐使用 GUID**,例如 `"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"`) -- `Version`:模块版本(语义化版本) -- `DisplayName`:模块对用户展示的名称 -- `Description`:模块描述 -- `SupportedHosts`:支持的宿主类型列表(如 `BlazorApp`, `AvaloniaApp`) -- `Assemblies`:该模块包含的程序集列表(含核心与 UI 程序集) -- `Dependencies`:对其它模块或运行时能力的依赖声明 - -**关系**: - -- 一个 `Module` 由一个或多个程序集实现; -- 一个 `Module` 需要映射到一个或多个 `PluginPackage`。 - ---- - -## 2. Host(宿主) - -**职责**: 提供外壳环境(窗口、生命周期、导航、菜单等),负责加载与管理模块。 - -**关键属性**: - -- `HostId`:宿主标识(如 `"BlazorDesktop"`, `"AvaloniaDesktop"`) -- `HostType`:宿主类型枚举(`Blazor`, `Avalonia`, ...) -- `Capabilities`:宿主提供的能力集合(窗口管理、通知、文档标签页等) -- `LoadedModules`:当前已加载模块列表 - -**关系**: - -- 一个 `Host` 可以同时加载多个 `Module`; -- 不同 `Host` 可以加载相同的 `Module` 核心程序集,但使用不同的 UI 程序集。 - ---- - -## 3. PluginPackage(插件包) - -**职责**: 作为部署与分发单位,将模块的程序集与资源打包在一起,供宿主发现与加载。 - -**关键属性**: - -- `PackageId`:插件包标识(通常与模块标识相关) -- `Version`:插件包版本 -- `Manifest`:`Manifest` 对象(见下一节) -- `ContentPath`:包内容所在路径(本地文件路径或解压目录) -- `SignatureInfo`:签名与完整性校验信息(如签名文件路径或证书指纹) - -**关系**: - -- 一个 `PluginPackage` 至少包含一个 `Module` 的实现; -- 一个 `Module` 在不同部署形式下可以对应多个 `PluginPackage`(例如社区版 / 企业版)。 - ---- - -## 4. Manifest(模块 / 插件清单) - -**职责**: 描述插件包中包含的模块信息、宿主支持情况与依赖关系,是运行时发现与加载的入口。 - -**关键属性**: - -- `Id`:模块 / 插件标识(与 `ModuleId` 对应) -- `Version`:模块版本 -- `Title`:显示名称 -- `Description`:描述 -- `Authors`:作者 / 组织信息 -- `SupportedHosts`:支持的宿主枚举列表 -- `CoreAssemblies`:核心程序集路径列表 -- `UiAssemblies`:按宿主类型分组的 UI 程序集路径列表 -- `Dependencies`:对其它模块或运行时能力的依赖 - -**关系**: - -- 一个 `Manifest` 与一个 `PluginPackage` 一一对应; -- 运行时通过解析 `Manifest` 构建 `Module` 对象与加载计划。 - ---- - -## 5. UIAbstractionContract(UI 抽象契约) - -**职责**: 定义模块表达 UI 意图的方式,屏蔽具体 UI 技术细节。 - -**核心接口示例(概念级,而非最终 API)**: - -- `IUIFactory`:根据 ViewModel / 标识创建视图或 UI 容器; -- `IViewHost`:承载视图的宿主接口,用于显示 / 关闭 / 激活视图; -- `IUiCommand`:描述可绑定到 UI 的命令; -- `NotificationContract`:描述通知 / 提示的显示方式。 - -**关系**: - -- 模块通过 `UIAbstractionContract` 与 `Host` 进行 UI 交互; -- 不同宿主提供各自的 UI 实现,但共享同一套抽象契约。 - ---- - -## 6. SDKBaseType(SDK 基类) - -**职责**: 为模块与插件作者(含 AI)提供强类型基类与扩展点,固化推荐模式。 - -**典型基类示意**: - -- `ModuleBase`:模块生命周期与依赖注册入口; -- `ToolPluginBase`:工具型插件基类,封装命令、UI 注册等; -- `DocumentPluginBase`:文档型 / 编辑器型插件基类。 - -**关系**: - -- SDK 基类通常依赖 `UIAbstractionContract` 与核心运行时服务; -- 插件 / 模块实现者通过继承 SDK 基类与实现特定虚方法完成注册。 - ---- - -## 7. RuntimeContext / ModuleRuntime(运行时上下文) - -**职责**: 表示运行中的模块系统状态,管理加载的模块、宿主与 ALC。 - -**关键属性**: - -- `LoadedModules`:已加载的 `Module` 集合 -- `Hosts`:已激活的 `Host` 集合 -- `AssemblyLoadContexts`:每个模块或模块组对应的 ALC 信息 -- `Mediator`:MediatR 实例或接口,用于请求 / 通知分发 - -**关系**: - -- 运行时在启动时创建 `RuntimeContext`,之后所有模块加载 / 卸载操作都经由此上下文协调; -- 该模型为后续实现插件监控、诊断与调试提供基础。 - - diff --git a/specs/001-core-architecture/plan.md b/specs/001-core-architecture/plan.md deleted file mode 100644 index b98ad62..0000000 --- a/specs/001-core-architecture/plan.md +++ /dev/null @@ -1,124 +0,0 @@ -# Implementation Plan: Modulus 核心架构与双宿主运行时 - -**Branch**: `[001-core-architecture]` | **Date**: 2025-11-27 | **Spec**: `specs/001-core-architecture/spec.md` -**Input**: Feature specification from `specs/001-core-architecture/spec.md` - -**Note**: This plan is generated for the `/speckit.plan` workflow and aligned with the Modulus Constitution. - -## Summary - -本特性旨在为 Modulus 提供一个 UI 无关的核心架构与双宿主运行时,使模块能够以垂直切片方式开发, -在 Blazor 风格宿主与 Avalonia 原生宿主下复用同一套 Domain / Application 逻辑。 -运行时将基于 `AssemblyLoadContext` 实现模块隔离与控制加载 / 卸载,通过 MediatR 处理模块间通信, -并通过统一的插件打包格式与 SDK 基类,为 AI 生成插件和人类开发者提供强类型扩展点。 - -Phase 1 (MVP) 聚焦于核心运行时、UI 抽象层、Web 风格宿主和最小可用 SDK; -Phase 2 (v1) 在此基础上补齐 Avalonia 宿主、完善 UI 抽象层、增强插件热重载与 SDK 能力。 - -## Technical Context - -**Language/Version**: .NET 10 (Current), C# (最新稳定版本;后续可评估 LTS 发布节奏进行调整) -**Primary Dependencies**: `MediatR`, `Avalonia`, `Blazor` (Hybrid / WebView 宿主), `Microsoft.Extensions.DependencyInjection`, `System.Text.Json` -**Storage**: N/A(本特性为框架级运行时与宿主,不绑定具体存储;业务模块可选择数据库 / 文件等) -**Testing**: xUnit + 集成测试(基于宿主运行时的端到端测试)+ 针对 SDK 与 manifest 的契约测试 -**Target Platform**: Windows 10+ / 最新 macOS(支持 Avalonia 原生宿主);后续可扩展到 Linux(Avalonia) -**Project Type**: 桌面 / 工具框架(多项目解决方案,单仓库,多宿主 + 多模块) -**Performance Goals**: 典型硬件上应用冷启动 < 3s,模块加载 / 卸载用户可感知延迟 < 2s;保持内存占用随模块数量线性可控 -**Constraints**: 核心层不得依赖具体 UI / Web 环境;插件加载 / 卸载需尽可能避免 `AssemblyLoadContext` 泄漏;必须符合宪章中金字塔分层与双宿主要求 -**Scale/Scope**: 初期包含 1–2 个内置模块 + 若干示例插件;解决方案预计包含若干 Core / Host / Modules / SDK 项目,后续可扩展到插件市场场景 - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -- **UI-Agnostic Core**: Does the proposed feature keep Domain/Application code free of any - concrete UI framework dependencies and use only `Modulus.UI.Abstractions` for UI contracts? -- **Dual-Engine Host Architecture**: Which hosts (Blazor, Avalonia) are targeted, and how will - UI assemblies be separated from core logic so the same module can run under multiple hosts? -- **Vertical Slice Modularity**: Which module(s) own this feature as a vertical slice, and can - they be loaded, tested, and versioned independently? -- **Pyramid Layering**: Do all dependencies follow - Presentation → UI Abstraction → Application → Domain → Infrastructure, with no cross-layer - shortcuts? -- **AI-Friendly Contracts & Plugin SDK**: What public contracts or SDK base types are required - or changed, and how will they remain strongly typed and self-describing for AI and human - authors? -- **Modern .NET & Technology Discipline**: Are the chosen runtime targets, MediatR usage, and - portability requirements consistent with the Modulus Constitution? - -Summarize any risks or intentional deviations here and link to the governance decision if -applicable. - -本实现计划严格遵守宪章中关于 UI 无关核心、双宿主架构、垂直切片模块化与金字塔分层的约束: -核心项目仅依赖 `Modulus.UI.Abstractions`,宿主分别封装 Blazor 与 Avalonia 相关依赖; -模块以 `Modulus.Modules..*` 命名的垂直切片组织,通过 DI 与 MediatR 解耦。 - -当前唯一需要在 Phase 0 研究阶段进一步细化的点是插件包的最终容器格式与签名方案 -(例如 Zip‑based `.modpkg` vs 基于 NuGet 的打包变体),但这些选项在设计上均可保持 -对宪章原则的兼容,不构成直接违反。 - -## Project Structure - -### Documentation (this feature) - -```text -specs/001-core-architecture/ -├── plan.md # This file (/speckit.plan command output) -├── research.md # Phase 0 output (/speckit.plan command) -├── data-model.md # Phase 1 output (/speckit.plan command) -├── quickstart.md # Phase 1 output (/speckit.plan command) -├── contracts/ # Phase 1 output (/speckit.plan command) -└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) -``` - -### Source Code (repository root) - -```text -src/ -├── Modulus.Core/ # 核心运行时与模块系统(ALC、模块发现、生命周期) -├── Modulus.UI.Abstractions/ # UI 抽象层接口与 DTO -├── Modulus.Sdk/ # 模块 / 插件 SDK(基类、辅助类型) -├── Hosts/ -│ ├── Modulus.Host.Blazor/ # Web 风格宿主(Blazor Hybrid / WebView 封装) -│ └── Modulus.Host.Avalonia/ # 原生桌面宿主(Avalonia) -├── Modules/ -│ ├── Modulus.Modules.Shell/ # 核心 Shell / 菜单 / 宿主集成模块 -│ ├── Modulus.Modules.Logging/ # 日志与诊断模块(示例) -│ └── Modulus.Modules.Samples/ # 示例模块集合(计算器等) -└── Shared/ - └── Modulus.Shared.Infrastructure/ # 共享基础设施实现(可选,避免业务逻辑泄漏) - -tests/ -├── Modulus.Core.Tests/ # 核心运行时与模块系统单测 / 集成测试 -├── Modulus.Hosts.Tests/ # 宿主层集成测试(启动、模块加载、UI 钩子) -├── Modulus.Modules.Tests/ # 模块层单测 / 集成测试 -└── Modulus.Sdk.Tests/ # SDK 契约与基类的契约测试 -``` - -**Structure Decision**: [Document the selected structure and reference the real -directories captured above] - -本计划采用“单仓库、多项目”的结构:以 `src/` 为根,按职责划分为 Core / UI.Abstractions / Hosts / -Modules / Sdk / Shared 若干项目目录,配套 `tests/` 目录按同样维度划分测试项目。 -这种结构有利于: - -- 清晰映射宪章中的分层与模块划分(例如 `Modulus.Modules..*` 垂直切片); -- 在同一解决方案中管理多个宿主与模块,方便跨项目引用与 CI 配置; -- 为后续 NuGet / `.modpkg` 打包提供稳定的项目边界。 - -备选方案包括: - -- 以 `apps/` + `src/` 结构区分宿主应用与可复用库; -- 以 `packages/` 结构直接对标未来 NuGet 包划分; - -当前选择上述 `src/` + `tests/` 结构作为 v1 基线,后续如需支持独立发布的宿主应用, -可以在根目录引入 `apps/` 目录承载打包与发布工程,而不改变 Core / Modules / Sdk 的结构。 - -## Complexity Tracking - -> **Fill ONLY if Constitution Check has violations that must be justified** - -| Violation | Why Needed | Simpler Alternative Rejected Because | -|-----------|------------|-------------------------------------| -| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | -| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/001-core-architecture/quickstart.md b/specs/001-core-architecture/quickstart.md deleted file mode 100644 index 5728228..0000000 --- a/specs/001-core-architecture/quickstart.md +++ /dev/null @@ -1,320 +0,0 @@ -# Quickstart: Modulus 模块开发指南 - -**Feature**: `001-core-architecture` -**Updated**: 2025-12-03 - -本文档帮助开发者快速上手 Modulus 模块开发。 - ---- - -## 1. 项目结构概览 - -```text -src/ -├── Modulus.Core/ # 核心运行时 (RuntimeContext, ModuleLoader, ModuleManager) -├── Modulus.Sdk/ # SDK 基类与属性 (ModuleBase, ModuleAttribute, etc.) -├── Modulus.UI.Abstractions/ # UI 抽象接口 (IMenuRegistry, IThemeService, etc.) -├── Hosts/ -│ ├── Modulus.Host.Blazor/ # Blazor Hybrid 宿主 (MAUI + MudBlazor) -│ └── Modulus.Host.Avalonia/ # Avalonia 桌面宿主 -└── Modules/ - ├── EchoPlugin/ # 示例: Echo 插件 - │ ├── EchoPlugin.Core/ - │ ├── EchoPlugin.UI.Avalonia/ - │ └── EchoPlugin.UI.Blazor/ - └── SimpleNotes/ # 示例: 笔记模块 - ├── SimpleNotes.Core/ - ├── SimpleNotes.UI.Avalonia/ - └── SimpleNotes.UI.Blazor/ -``` - ---- - -## 2. 创建新模块 - -### 2.1 项目结构 - -每个模块由三个项目组成: - -| 项目 | 类型 | 引用 | -|------|------|------| -| `MyModule.Core` | Class Library | `Modulus.Sdk`, `Modulus.UI.Abstractions` | -| `MyModule.UI.Avalonia` | Class Library | `MyModule.Core`, `Avalonia` | -| `MyModule.UI.Blazor` | Razor Class Library | `MyModule.Core`, `MudBlazor` | - -### 2.2 Core 模块类 - -```csharp -using Modulus.Sdk; -using Modulus.Sdk.Attributes; - -namespace MyModule.Core; - -[Module( - Id = "my-module-guid-here", - DisplayName = "My Module", - Description = "A sample module")] -public class MyModuleModule : ModuleBase -{ - public override void ConfigureServices(IModuleLifecycleContext context) - { - // Register services - context.Services.AddTransient(); - } -} -``` - -### 2.3 ViewModel (使用 CommunityToolkit.Mvvm) - -```csharp -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; - -namespace MyModule.Core.ViewModels; - -public partial class MyViewModel : ObservableObject -{ - [ObservableProperty] - private string _title = "My Module"; - - [ObservableProperty] - private string _inputText = string.Empty; - - [RelayCommand] - private void DoSomething() - { - // Business logic here - } -} -``` - -### 2.4 Avalonia UI 模块 - -```csharp -using Modulus.Sdk; -using Modulus.Sdk.Attributes; - -namespace MyModule.UI.Avalonia; - -[DependsOn(typeof(MyModuleModule))] -[AvaloniaMenu( - DisplayName = "My Module", - Icon = "🔧", - ViewModelType = typeof(MyViewModel), - Location = MenuLocation.Main, - Order = 50)] -public class MyModuleAvaloniaModule : ModuleBase -{ - public override Task OnApplicationInitializationAsync( - IModuleInitializationContext context, - CancellationToken cancellationToken = default) - { - var viewRegistry = context.ServiceProvider.GetRequiredService(); - viewRegistry.Register(); - return Task.CompletedTask; - } -} -``` - -### 2.5 Blazor UI 模块 - -```csharp -using Modulus.Sdk; -using Modulus.Sdk.Attributes; - -namespace MyModule.UI.Blazor; - -[DependsOn(typeof(MyModuleModule))] -[BlazorMenu( - DisplayName = "My Module", - Icon = "extension", // MudBlazor icon name - Route = "/mymodule", - Location = MenuLocation.Main, - Order = 50)] -public class MyModuleBlazorModule : ModuleBase -{ - // Blazor uses route-based navigation, no view registration needed -} -``` - ---- - -## 3. Manifest 配置 - -每个模块需要一个 `manifest.json` 文件: - -```json -{ - "manifestVersion": "1.0", - "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", - "version": "1.0.0", - "displayName": "My Module", - "description": "A sample module for demonstration.", - "supportedHosts": ["BlazorApp", "AvaloniaApp"], - "coreAssemblies": ["MyModule.Core.dll"], - "uiAssemblies": { - "BlazorApp": ["MyModule.UI.Blazor.dll"], - "AvaloniaApp": ["MyModule.UI.Avalonia.dll"] - }, - "dependencies": {} -} -``` - -**重要**: -- `id` 推荐使用 GUID 以确保唯一性 -- `manifest.json` 需要复制到输出目录(在 `.csproj` 中配置) - -```xml - - - -``` - ---- - -## 4. 模块生命周期 - -模块生命周期方法按以下顺序调用: - -1. **ConfigureServices** - 注册 DI 服务 -2. **PreConfigureAsync** - 预配置(依赖模块之前) -3. **ConfigureAsync** - 主配置 -4. **PostConfigureAsync** - 后配置(依赖模块之后) -5. **OnApplicationInitializationAsync** - 应用初始化(注册视图、菜单等) -6. **OnApplicationShutdownAsync** - 应用关闭时清理 - -```csharp -public class MyModuleModule : ModuleBase -{ - public override void ConfigureServices(IModuleLifecycleContext context) - { - // Step 1: Register services - } - - public override Task OnApplicationInitializationAsync( - IModuleInitializationContext context, - CancellationToken cancellationToken = default) - { - // Step 5: Register menus, views, etc. - return Task.CompletedTask; - } - - public override Task OnApplicationShutdownAsync( - IModuleInitializationContext context, - CancellationToken cancellationToken = default) - { - // Step 6: Cleanup - return Task.CompletedTask; - } -} -``` - ---- - -## 5. 依赖管理 - -使用 `[DependsOn]` 属性声明模块依赖: - -```csharp -[DependsOn(typeof(CoreModule), typeof(LoggingModule))] -public class MyModuleModule : ModuleBase -{ - // This module will be initialized after CoreModule and LoggingModule -} -``` - ---- - -## 6. 宿主类型 - -Modulus 支持两种宿主类型: - -| 宿主 | 标识符 | UI 框架 | -|------|--------|---------| -| Blazor Hybrid | `BlazorApp` | MAUI + MudBlazor | -| Avalonia | `AvaloniaApp` | Avalonia UI | - -模块可以通过 `RuntimeContext.HostType` 获取当前宿主类型。 - ---- - -## 7. UI 抽象接口 - -### IMenuRegistry -注册导航菜单项: - -```csharp -var menuRegistry = context.ServiceProvider.GetRequiredService(); -menuRegistry.Register(new MenuItem( - id: "my-menu", - displayName: "My Module", - icon: "🔧", - navigationKey: typeof(MyViewModel).FullName!, - location: MenuLocation.Main, - order: 50)); -``` - -### IThemeService -管理应用主题: - -```csharp -var themeService = context.ServiceProvider.GetRequiredService(); -themeService.SetTheme(AppTheme.Dark); -``` - -### INotificationService -显示通知: - -```csharp -var notificationService = context.ServiceProvider.GetRequiredService(); -await notificationService.ShowInfoAsync("Title", "Message"); -``` - ---- - -## 8. 数据持久化 - -Modulus 使用 SQLite + EF Core 存储应用设置和模块状态: - -### ISettingsService -存取应用设置: - -```csharp -var settings = context.ServiceProvider.GetRequiredService(); - -// Get setting with default value -var theme = settings.GetSetting("AppTheme", AppTheme.System); - -// Set setting -settings.SetSetting("AppTheme", AppTheme.Dark); -``` - ---- - -## 9. 运行与调试 - -### 启动 Avalonia 宿主 -```bash -dotnet run --project src/Hosts/Modulus.Host.Avalonia -``` - -### 启动 Blazor 宿主 -```bash -dotnet run --project src/Hosts/Modulus.Host.Blazor -``` - -### 运行测试 -```bash -dotnet test -``` - ---- - -## 10. 最佳实践 - -1. **保持 Core 模块 UI 无关** - 不要在 Core 项目中引用任何 UI 框架 -2. **使用 GUID 作为模块 ID** - 确保模块标识的唯一性 -3. **使用声明式属性** - 优先使用 `[Module]`, `[AvaloniaMenu]`, `[BlazorMenu]` 属性 -4. **遵循 MVVM 模式** - 使用 CommunityToolkit.Mvvm 实现 ViewModel -5. **正确配置 manifest.json** - 确保复制到输出目录 -6. **测试驱动** - 为模块编写单元测试和集成测试 diff --git a/specs/001-core-architecture/research.md b/specs/001-core-architecture/research.md deleted file mode 100644 index 64cc701..0000000 --- a/specs/001-core-architecture/research.md +++ /dev/null @@ -1,101 +0,0 @@ -# Research: Modulus 核心架构与双宿主运行时 - -**Feature**: `001-core-architecture` -**Date**: 2025-11-27 - -本研究文档用于在实现前澄清关键架构决策,解决规格中的 NEEDS CLARIFICATION,并为后续设计与实现提供依据。 - ---- - -## 1. 插件打包格式与签名方案 - -### Decision - -采用自定义的 Zip‑based 容器格式(暂定扩展名为 `.modpkg`),内部使用清晰的目录结构与 `manifest.json` -描述模块与插件的元数据。 -签名方案优先通过标准的文件签名机制(如 Authenticode 或外部签名文件)集成,而不是把签名逻辑 -硬编码在容器格式中。 - -### Rationale - -- Zip 容器跨平台、易于在 .NET 中读写,生态成熟; -- 自定义扩展名 `.modpkg` 可以与普通 Zip 区分,便于工具与宿主发现; -- 使用 JSON manifest 便于 AI 与人类阅读和生成; -- 将签名视为独立 concern,允许未来支持多种签名与校验方案,而不锁定在单一实现上。 - -### Alternatives considered - -1. **直接使用 NuGet 包 (`.nupkg`)** - - Pros: 与现有 .NET 生态高度兼容,工具链成熟。 - - Cons: NuGet 语义偏向代码分发与依赖管理,不完全匹配运行时插件加载场景; - 同时会引入与普通依赖管理混淆的风险。 - -2. **裸目录结构(无容器,仅文件夹)** - - Pros: 开发调试简单,零额外封装。 - - Cons: 发布与分发体验较差,难以进行完整性校验,且不利于跨平台复制与备份。 - ---- - -## 2. Blazor 宿主承载方式 - -### Decision - -Phase 1 中,将 Blazor 宿主抽象为“基于 WebView 的桌面宿主”,在架构上不锁定具体实现 -(如 .NET MAUI Hybrid 或 Photino),而是通过一层 Host 抽象封装。具体技术选型可以在 -宿主项目中根据目标平台与维护成本做最终决定。 - -### Rationale - -- 当前生态中,.NET MAUI 与 Photino 均可承载 Blazor UI,且各有优劣; -- 在架构层面对“Blazor 宿主”进行抽象,有利于后续根据实际经验切换或并存多种实现; -- 对模块与 SDK 而言,关键在于「存在一个 Web 风格宿主」,而不是具体用哪种技术堆栈。 - -### Alternatives considered - -1. **强制使用 .NET MAUI Hybrid** - - Pros: 官方支持、集成度高,对移动平台扩展有潜力。 - - Cons: 对桌面工具型场景(尤其是已有桌面环境)可能显得偏重。 - -2. **强制使用 Photino / WebWindow 等轻量宿主** - - Pros: 更贴近桌面工具需求,依赖更少,打包体积可能更小。 - - Cons: 社区生态与长期维护情况需要评估。 - ---- - -## 3. 测试框架与测试策略 - -### Decision - -采用 **xUnit** 作为主单元测试框架,配合: - -- 核心运行时与模块系统的单元测试与集成测试(`Modulus.Core.Tests`); -- 宿主层端到端测试(`Modulus.Hosts.Tests`),通过自动化启动宿主、加载示例模块; -- SDK 契约测试(`Modulus.Sdk.Tests`),确保基类行为与文档 / 示例一致。 - -### Rationale - -- xUnit 在 .NET 社区使用广泛,生态与工具支持成熟; -- 现有许多开源项目使用 xUnit,方便贡献者迁移习惯; -- 与 Nuke 等构建工具集成简单。 - -### Alternatives considered - -1. **NUnit** - - Pros: 历史悠久、功能丰富。 - - Cons: 与当前社区主流趋势相比略弱,团队习惯需统一。 - -2. **MSTest** - - Pros: 与部分微软工具链天然集成。 - - Cons: 社区示例与生态相对较少,不利于插件作者快速上手。 - ---- - -## 4. 未决问题与后续研究方向 - -- 插件签名的具体实现路径(使用哪种证书体系、如何在 CI/CD 中集成); -- 是否需要支持进程外插件(Out-of-Process),以及如何与当前 ALC 模式共存; -- 针对大型插件市场场景的版本兼容策略与回滚策略。 - -这些问题不阻塞 Phase 1 / Phase 2 的基本架构设计,但需要在后续 Story / Spec 中单独展开。 - - diff --git a/specs/001-core-architecture/spec.md b/specs/001-core-architecture/spec.md deleted file mode 100644 index cf366b9..0000000 --- a/specs/001-core-architecture/spec.md +++ /dev/null @@ -1,231 +0,0 @@ -# Feature Specification: Modulus 核心架构与双宿主运行时 - -**Feature Branch**: `[001-core-architecture]` -**Created**: 2025-11-27 -**Status**: Draft -**Input**: User description: "Design the core architecture and dual-host runtime for the Modulus modular .NET desktop framework." - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - 一次开发,双宿主运行 (Priority: P1) - -作为模块 / 插件开发者,我可以只围绕 UI 无关的核心(Domain + Application)和统一 SDK 实现一个模块, -并在不修改业务代码的前提下,让同一套核心逻辑分别在 Web 风格宿主和原生桌面宿主下运行。 - -**Why this priority**: 这是 Modulus 的核心价值主张:在不同 UI 环境下复用一套业务逻辑,降低开发与维护成本。 - -**Independent Test**: 实现一个简单的垂直切片示例模块(例如计算器或日志查看器),验证其 -Domain / Application 程序集在 Blazor 风格宿主与 Avalonia 宿主下完全相同,仅通过不同 UI 程序集 -即可完成两端运行。 - -**Acceptance Scenarios**: - -1. **Given** 一个包含 Domain / Application 程序集以及对应 UI 程序集的模块 - **When** 将该模块加载到 Web 风格宿主中 - **Then** 该功能可以在该宿主内完整使用,行为与预期一致,UI 流程可用。 - -2. **Given** 相同的核心程序集 - **When** 将该模块加载到原生桌面宿主中 - **Then** 在不修改 Domain / Application 代码的前提下,功能同样可用,行为等价(样式可以不同但体验应一致)。 - ---- - -### User Story 2 - 运行时安全启用 / 禁用模块 (Priority: P1) - -作为框架维护者或高级用户,我可以在不重启整个应用的前提下,在运行时启用、禁用和重新加载模块 / 插件, -并保证其它已加载模块不会因此失效。 - -**Why this priority**: 运行时可插拔与安全边界是构建可扩展工具平台、支持热重载与快速迭代的基础能力。 - -**Independent Test**: 启动宿主加载一组模块,然后在同一进程内多次重复对某一个模块执行启用 / 禁用 / 重新加载操作, -验证其它模块始终正常工作、宿主稳定不崩溃。 - -**Acceptance Scenarios**: - -1. **Given** 宿主当前已加载多个模块 - **When** 通过运行时管理接口禁用或卸载某个模块 - **Then** 该模块的 UI 与行为会被干净移除,其它模块继续正常工作且无异常。 - -2. **Given** 同一宿主会话 - **When** 重新启用或重新加载之前被禁用的模块 - **Then** 该模块重新出现,其初始化逻辑正常执行,不会破坏共享状态或其它模块。 - ---- - -### User Story 3 - 基于 SDK 的 AI 辅助插件开发 (Priority: P2) - -作为使用 AI 助手的开发者(或 AI Agent 本身),我可以基于一组强类型的 SDK 基类与清晰契约, -自动生成一个简单插件或模块,使其在无需手工调整整体结构的情况下即可编译、打包并在运行时加载执行。 - -**Why this priority**: 对 AI 友好的契约能让 Modulus 成为快速生成工具与自动化插件的平台。 - -**Independent Test**: 使用官方 SDK 基类与示例模版,通过 AI 生成一个简单的 “echo” 或 “calculator” 插件, -然后按标准流程编译、打包并加载到运行时中完成端到端验证。 - -**Acceptance Scenarios**: - -1. **Given** 官方 SDK 基类与示例模版 - **When** AI Agent 按照模版生成一个新的插件实现 - **Then** 插件在不修改结构的前提下可以成功编译,并能按定义的打包格式打包。 - -2. **Given** 生成的插件包 - **When** 宿主通过标准发现机制加载该插件 - **Then** 插件会出现在宿主 UI 中,可被调用,并按照契约定义的行为正确执行。 - ---- - -### Edge Cases - -- 当插件或模块 manifest 缺失必需字段、字段无效或引用的程序集无法加载时,系统应该如何处理? -- 当某个模块在初始化阶段失败(例如启动代码抛异常),但其它模块已成功加载时,系统如何隔离与降级? -- 当某个模块被两个宿主请求加载,但其只提供了单一宿主的 UI 程序集时(例如仅提供 Blazor UI),系统应该如何降级或发出提示? - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: 框架必须提供一个模块运行时,用于发现、加载、启用、禁用和卸载模块 / 插件, - 在常见路径下不需要重启整个应用。 - -- **FR-002**: 各模块的 Domain 与 Application 层必须是 UI 无关的,只能依赖 - `Modulus.UI.Abstractions` 等 UI 抽象层契约,而不能直接引用具体 UI 框架(如 Blazor、Avalonia 等)。 - -- **FR-003**: 架构必须支持至少两种一等宿主类型:Web 风格宿主与原生桌面宿主,使相同的 - Domain / Application 程序集可以在两种宿主下运行。 - -- **FR-004**: 系统必须以垂直切片模块为主要交付单元,每个功能模块需要明确区分: - - **Business Module (Core)**: 包含 Domain / Application 层,**全宿主通用**,不依赖特定 UI 技术栈。 - - **UI Adapter Module (Host-Specific)**: 包含 Presentation 层,依赖 Core 模块,并**明确标记**适配哪个宿主(Blazor, Avalonia, React 等)。宿主加载时必须仅加载匹配其类型的 UI 适配器。 - -- **FR-005**: 运行时必须通过 MediatR(或等价的强类型进程内消息机制)处理模块之间的通信, - 禁止通过直接依赖其它模块实现类的方式进行耦合。 - -- **FR-006**: 必须设计一种插件 / 模块打包格式(概念上类似 `.modpkg` 容器),其中包含: - - 描述标识、版本、依赖、支持宿主等信息的 manifest; - - 一个或多个核心程序集(Domain / Application / Infrastructure); - - 每种宿主类型对应的 0~N 个 UI 程序集。 - -- **FR-007**: 运行时必须支持基于 `AssemblyLoadContext`(或等价机制)的隔离, - 使模块 / 插件可以在一定程度上独立加载与卸载,并尽量减少对其它模块的影响。 - -- **FR-008**: 框架必须提供强类型的 SDK 基类与接口,用于模块与插件开发 - (例如工具型插件、文档型插件、模块基类等),在其中编码推荐的初始化、生命周期和 UI 注册模式。 - -- **FR-009**: 在 **Phase 1 (MVP)** 范围内,架构至少需要交付: - - 核心运行时与模块系统; - - UI 抽象层; - - 一种 Web 风格宿主(如 Blazor-based)及至少一个端到端示例模块; - - 一套最小可用的插件 SDK,使简单插件可以被 AI 辅助生成。 - -- **FR-010**: 在 **Phase 2 (v1)** 中,架构需要在 Phase 1 基础上: - - 增加第二种宿主类型(如 Avalonia 原生宿主); - - 将 UI 抽象层完善为可在两种宿主间一致工作的契约; - - 支持在进程内的插件热重载 / 卸载(在合理约束前提下); - - 扩展 SDK 能力以支持更复杂的插件形式。 - -- **FR-011**: 打包与 manifest 模型必须从设计上支持版本化,以便将来增加新字段或能力时, - 不会破坏现有插件。 - -- **FR-012**: 架构必须明确划分宿主与模块的职责边界:宿主负责外部环境(窗口、导航、菜单等), - 模块负责业务逻辑、UI 契约与插件行为。 - -- **FR-013**: 必须确定插件包的具体容器格式与签名方案(例如 Zip‑based `.modpkg`、NuGet 变体等), - 并说明如何校验插件的来源可信与内容完整。 - - **FR-014**: 模块加载器必须具备**宿主感知能力**。在加载模块时,除了加载核心程序集外, - 只能加载与当前运行宿主(Current Host Type)匹配的 UI 程序集。 - Manifest 中的 `uiAssemblies` 字段应作为查找依据。SDK 层面应提供特性(如 `[HostAffinity]`) - 以进一步校验 UI 模块的兼容性。 - -### Key Entities *(include if feature involves data)* - -- **Module(模块)**: 表示一个垂直切片功能,可能包含 Domain、Application、Infrastructure 以及可选的 - Presentation / UI 程序集,具有唯一标识、版本和支持宿主等元数据。 - -- **Host(宿主)**: 提供环境相关能力(例如窗口、导航、顶层菜单)的外壳应用,根据模块元数据加载模块。 - 至少包括 Web 风格宿主与原生桌面宿主两种。 - -- **PluginPackage(插件包)**: 一个可部署与发现的制品(例如 `.modpkg`),内部包含 manifest、 - 核心程序集、可选的各宿主 UI 程序集以及资源文件,是外部插件的发布单位。 - -- **UIAbstractionContract(UI 抽象契约)**: 一组接口与 DTO(例如 View 工厂、View 宿主、消息契约等), - 用于描述模块如何表达 UI 意图,而不直接依赖具体 UI 框架。 - -- **SDKBaseType(SDK 基类)**: 面向模块与插件开发提供的强类型基类与辅助类型, - 在其中固化推荐模式,简化人类与 AI 的开发体验。 - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: 至少有一个示例垂直切片模块可以在仅构建一次 - Domain / Application / Infrastructure 程序集的前提下,分别在两种宿主下成功运行, - 且这两种运行方式之间不需要修改这些核心程序集。 - -- **SC-002**: 在一轮测试会话中,对同一个模块执行启用、禁用与重新加载操作至少 100 次, - 无需重启应用,且其它已加载模块始终保持正常工作。 - -- **SC-003**: 至少有一个通过 AI 辅助、基于官方 SDK 基类自动生成的简单插件可以: - - 在不调整整体结构的前提下成功编译; - - 按指定打包格式打包; - - 被运行时加载并完成端到端调用。 - -- **SC-004**: 在目标环境的典型硬件配置下,加载或卸载单个模块的用户可感知延迟在合理范围内 - (例如常规场景下可在 2 秒内完成,使用户感觉“几乎即时”)。 - -## Architecture & Modulus Constraints *(mandatory for this repository)* - -### Module & Host Mapping - -- Owning module(s) (vertical slice): - - `Modulus.Core`(核心运行时与模块系统) - - `Modulus.UI.Abstractions`(UI 抽象契约) - - `Modulus.Host.Blazor`(Web 风格宿主模块) - - `Modulus.Host.Avalonia`(原生桌面宿主模块) - - `Modulus.Sdk`(模块 / 插件 SDK) - -- Target host(s): - - Web-style host(基于 Blazor 的宿主) - - Native desktop host(基于 Avalonia 的宿主) - -- UI assemblies involved (if any): - - 宿主级 UI 程序集(如 `Modulus.Host.Blazor.UI`, `Modulus.Host.Avalonia.UI`) - - 示例模块 UI 程序集(如 `ExampleModule.UI.Blazor.dll`, `ExampleModule.UI.Avalonia.dll`) - -### Layering & Dependencies - -- Layers touched by this feature: - - Presentation - - UI Abstraction - - Application - - Domain - - Infrastructure - -- Any required cross-module communication (via MediatR or explicit interfaces): - - 模块发现、加载 / 卸载与状态通知通过 MediatR 请求 / 通知完成; - - 宿主向模块发送与导航、窗口 / 工具窗口创建、环境事件相关的消息; - - 模块通过 UI 抽象层发布 UI 意图(如打开视图、显示通知),而不是直接操作具体 UI 框架。 - -- Confirm there are no planned violations of the dependency pyramid: - - Domain / Application 项目不得引用宿主特定 UI 框架或环境特定 API; - - Presentation 与宿主项目可以依赖 UI 框架,但只能通过 UI 抽象层契约、Application 服务与 MediatR - 与核心逻辑交互; - - 若存在任何计划性例外,必须记录在架构说明中,并关联到宪章治理决策。 - -### Public Contracts & SDK Impact - -- New or changed public contracts / DTOs: - - 模块与插件 manifest 模型(标识、版本、能力、支持宿主等); - - UI 抽象接口(如 View 工厂、View 宿主、消息契约); - - 模块生命周期契约(初始化、启动、停止、释放等)。 - -- New or updated SDK base types (if any): - - 垂直切片模块基类; - - 工具 / 文档类插件基类,用于在两个宿主下进行 UI 集成; - - 打包与 manifest 辅助类型,统一插件自描述方式。 - -- Backward compatibility and migration notes: - - 本规格定义了 Modulus 核心架构与双宿主运行时的初始基线; - - 公共契约与 manifest 必须支持版本化,以便未来引入 - 进程外插件、更多宿主类型等能力时不破坏现有 v1 模块 / 插件; - - 任何后续破坏性变更必须遵循 Modulus 宪章中的版本管理与迁移策略要求, - 并同步更新面向 AI 的 SDK 文档与示例。 \ No newline at end of file diff --git a/specs/001-core-architecture/tasks.md b/specs/001-core-architecture/tasks.md deleted file mode 100644 index 48718a0..0000000 --- a/specs/001-core-architecture/tasks.md +++ /dev/null @@ -1,205 +0,0 @@ ---- - -description: "Tasks for implementing Modulus 核心架构与双宿主运行时" ---- - -# Tasks: Modulus 核心架构与双宿主运行时 - -**Input**: Design documents from `specs/001-core-architecture/` -**Prerequisites**: `plan.md` (required), `spec.md` (required), `research.md`, `data-model.md`, `contracts/`, `quickstart.md` - -**Tests**: 本特性涉及框架与运行时,建议为关键路径添加测试任务(单元测试 + 集成测试),但数量可随实现阶段调整。 -**Organization**: 任务按 User Story 分组,确保每一 Story 都可独立实现与验证。 - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: 可并行执行(不同文件、无依赖) -- **[Story]**: 任务归属的 User Story(US1, US2, US3) -- 描述中必须包含明确文件路径 - ---- - -## Phase 1: Setup (Shared Infrastructure) - -**Purpose**: 创建基础目录与项目结构,为后续实现提供落地点。 - -- [X] T001 创建基础目录结构 `src/` 与 `tests/`(如不存在)于仓库根目录 - (修改路径:`D:\src\tools\Modulus\`) -- [X] T002 [P] 创建核心运行时项目 `src/Modulus.Core/Modulus.Core.csproj` 并将其加入 `Modulus.sln` - (修改文件:`Modulus.sln`) -- [X] T003 [P] 创建 UI 抽象层项目 `src/Modulus.UI.Abstractions/Modulus.UI.Abstractions.csproj` 并加入解决方案 - (修改文件:`Modulus.sln`) -- [X] T004 [P] 创建 SDK 项目 `src/Modulus.Sdk/Modulus.Sdk.csproj` 并加入解决方案 - (修改文件:`Modulus.sln`) -- [X] T005 [P] 在 `src/Hosts/` 下创建 Blazor 宿主项目 `Modulus.Host.Blazor/Modulus.Host.Blazor.csproj` - (修改文件:`Modulus.sln`) -- [X] T006 [P] 在 `src/Hosts/` 下创建 Avalonia 宿主项目 `Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj` - (修改文件:`Modulus.sln`) -- [X] T007 [P] 在 `src/Modules/` 下创建 Shell 模块项目 `Modulus.Modules.Shell/Modulus.Modules.Shell.csproj` - (修改文件:`Modulus.sln`) -- [X] T008 [P] 在 `src/Modules/` 下创建示例模块项目 `Modulus.Modules.Samples/Modulus.Modules.Samples.csproj` - (修改文件:`Modulus.sln`) -- [X] T009 [P] 创建测试项目 `tests/Modulus.Core.Tests/Modulus.Core.Tests.csproj` 并配置到 `Modulus.sln` -- [X] T010 [P] 创建测试项目 `tests/Modulus.Hosts.Tests/Modulus.Hosts.Tests.csproj` 并配置到 `Modulus.sln` -- [X] T011 [P] 创建测试项目 `tests/Modulus.Modules.Tests/Modulus.Modules.Tests.csproj` 并配置到 `Modulus.sln` -- [X] T012 [P] 创建测试项目 `tests/Modulus.Sdk.Tests/Modulus.Sdk.Tests.csproj` 并配置到 `Modulus.sln` - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: 完成所有 User Story 之前必须具备的核心运行时与架构基础。 - -**⚠️ CRITICAL**: 在本阶段完成前,不应开始任何具体 User Story 的业务实现。 - -- [X] T013 在 `src/Modulus.Core/` 中实现基础依赖注入与日志基础设施(使用 `Microsoft.Extensions.DependencyInjection` 和 logging) -- [X] T014 在 `src/Modulus.Core/Runtime/` 下定义并实现核心实体:`Module`, `Host`, `PluginPackage`, `Manifest`, `RuntimeContext`(与 `data-model.md` 一致) -- [X] T015 在 `src/Modulus.Core/Runtime/` 中实现基于 `AssemblyLoadContext` 的模块加载器(创建 / 卸载 ALC,加载模块程序集) -- [X] T016 在 `src/Modulus.Core/Manifest/` 中实现 `.modpkg` 容器与 `manifest.json` 的解析逻辑(路径映射、支持宿主信息、依赖列表) -- [X] T017 在 `src/Modulus.Core/Runtime/` 中集成 `MediatR`,配置模块级与跨模块请求 / 通知分发 -- [X] T018 在 `src/Modulus.UI.Abstractions/` 中定义 UI 抽象接口(`IUIFactory`, `IViewHost`, `INotificationService` 等) -- [X] T019 在 `src/Modulus.Sdk/` 中引入基础 SDK 契约与空实现骨架(`ModuleBase`, `ToolPluginBase`, `DocumentPluginBase` 等) -- [X] T020 在 `tests/Modulus.Core.Tests/` 中编写最小集成测试:验证加载一个 `.modpkg` 包能创建 `Module` 并注册到 `RuntimeContext` -- [X] T021 [P] 在 `tests/Modulus.Sdk.Tests/` 中为 `ModuleBase` 与 `ToolPluginBase` 添加基本契约测试(生命周期调用顺序与必需回调) -- [X] T022 检查并调整解决方案引用,确保遵守 `Presentation → UI Abstraction → Application → Domain → Infrastructure` 依赖金字塔(主要修改 `*.csproj`) -- [X] T023 确认 `src/Modulus.Core/` 与 `src/Modulus.Sdk/` 不引用任何具体 UI 框架命名空间(Blazor / Avalonia),必要时通过分析与重构移除 - -**Checkpoint**: 基础运行时、UI 抽象、SDK 骨架与分层依赖全部就绪,可以开始按 User Story 分阶段实现。 - ---- - -## Phase 3: User Story 1 - 一次开发,双宿主运行 (Priority: P1) 🎯 MVP - -**Goal**: 实现一个示例垂直切片模块,使其在 Web 风格宿主与 Avalonia 宿主下共用同一套核心逻辑运行。 - -**Independent Test**: 构建一次示例模块的 Domain / Application 程序集,在不修改这些程序集的前提下, -分别运行 Blazor 宿主与 Avalonia 宿主,并验证示例功能在两者中行为一致。 - -### Implementation for User Story 1 - -- [X] T024 [P] [US1] 在 `src/Modules/Modulus.Modules.Samples/Domain/` 中实现示例模块的 Domain 模型与服务(例如计算器 / 简单工具) -- [X] T025 [P] [US1] 在 `src/Modules/Modulus.Modules.Samples/Application/` 中实现用例与 Application 服务,依赖 Domain 模型与 `Modulus.UI.Abstractions` -- [X] T026 [P] [US1] 在 `src/Modules/Modulus.Modules.Samples/UI.Blazor/` 下实现 Blazor 视图与适配层,通过 `IUIFactory` 与 Application 服务交互 -- [X] T027 [P] [US1] 在 `src/Modules/Modulus.Modules.Samples/UI.Avalonia/` 下实现 Avalonia 视图与适配层,通过 `IUIFactory` 与 Application 服务交互 -- [X] T028 [US1] 为示例模块编写 manifest 文件(例如 `src/Modules/Modulus.Modules.Samples/manifest.json`),填充模块标识、版本、支持宿主与程序集列表 -- [X] T029 [US1] 在 `src/Modulus.Core/Runtime/` 中接入示例模块的 manifest 与 `.modpkg` 打包,使宿主可发现并加载该模块 -- [X] T030 [US1] 在 `src/Hosts/Modulus.Host.Blazor/` 中添加示例模块入口(菜单 / 工具面板注册)并验证交互闭环 -- [X] T031 [US1] 在 `src/Hosts/Modulus.Host.Avalonia/` 中添加示例模块入口(窗口 / 面板注册)并验证交互闭环 -- [X] T032 [P] [US1] 在 `tests/Modulus.Hosts.Tests/` 中添加端到端测试:在两种宿主下分别加载并调用示例模块,验证行为一致 - -**Checkpoint**: 示例模块在 Blazor 宿主与 Avalonia 宿主下均可用,且共享相同的核心业务程序集。 - ---- - -## Phase 4: User Story 2 - 运行时安全启用 / 禁用模块 (Priority: P1) - -**Goal**: 支持在不重启应用的前提下安全启用、禁用与重新加载模块,并确保其它模块不受影响。 - -**Independent Test**: 在同一进程内重复对一个模块执行启用 / 禁用 / 重新加载操作多次,验证其它模块与宿主稳定性。 - -### Implementation for User Story 2 - -- [X] T033 [US2] 在 `src/Modulus.Core/Runtime/` 中扩展 `RuntimeContext` 与模块管理 API,支持 enable/disable/reload 操作 -- [X] T034 [US2] 在 `src/Modulus.Core/Runtime/` 中实现模块卸载时的清理逻辑(释放 ALC、注销 DI 注册等),避免资源泄漏 -- [X] T035 [US2] 在 `src/Hosts/` 各宿主中实现模块管理 UI(列表 / 状态 / 操作按钮),Shell 作为宿主内置组件 -- [X] T036 [P] [US2] 在 `src/Hosts/Modulus.Host.Blazor/` 中集成 Shell 模块的管理 UI(Modules 页面) -- [X] T037 [P] [US2] 在 `src/Hosts/Modulus.Host.Avalonia/` 中集成 Shell 模块的管理 UI(ModuleListView) -- [X] T038 [US2] 在 `tests/Modulus.Hosts.Tests/` 中添加端到端测试:对示例模块连续执行多次 enable/disable/reload,验证不崩溃且状态正确恢复 - -**Checkpoint**: 模块管理 UI 与运行时 API 可协同完成模块启用 / 禁用 / 重新加载,并通过自动化测试验证稳定性。 - ---- - -## Phase 5: User Story 3 - 基于 SDK 的 AI 辅助插件开发 (Priority: P2) - -**Goal**: 提供强类型 SDK 基类与最小示例,使 AI 可以基于这些基类生成可编译、可打包并可运行的插件。 - -**Independent Test**: 使用 SDK 基类为一个简单工具型插件生成代码(可由 AI 生成),在不调整整体结构的前提下完成编译、打包与加载。 - -### Implementation for User Story 3 - -- [X] T039 [US3] 在 `src/Modulus.Sdk/` 中完善 `ModuleBase`, `ToolPluginBase`, `DocumentPluginBase` 等基类的公共 API(生命周期、注册点、错误处理模式) -- [X] T040 [US3] 在 `src/Modulus.Sdk/` 中添加用于生成 manifest 与 `.modpkg` 结构的辅助类型(例如 `PluginPackageBuilder`) -- [X] T041 [US3] 在 `src/Modules/Modulus.Modules.Samples/` 下添加一个基于 SDK 的示例插件实现(例如 Echo 工具),严格遵循 SDK 模式 - (实际位置:`src/Modules/EchoPlugin`) -- [X] T042 [P] [US3] 在 `tests/Modulus.Sdk.Tests/` 中添加契约测试:验证基于 SDK 的示例插件能够完成初始化与注册流程 - (实际位置:`tests/Modulus.Modules.Tests/EchoPluginTests.cs`) -- [X] T043 [US3] 在 `specs/001-core-architecture/contracts/runtime-contracts.md` 中补充/更新与 SDK 相关的公共接口说明,使其与代码保持一致 -- [X] T044 [US3] 更新 `specs/001-core-architecture/quickstart.md`,加入“使用 SDK 创建第一个插件”的简要步骤 - -**Checkpoint**: 存在至少一个通过 SDK 开发的示例插件,可以作为 AI 生成插件的参考模板,并通过测试验证。 - ---- - -## Phase N: Polish & Cross-Cutting Concerns - -**Purpose**: 面向整体架构与开发体验的收尾与跨模块优化。 - -- [X] T045 [P] 完成对 `specs/001-core-architecture/` 下所有文档的最终对齐(spec, plan, data-model, quickstart, contracts) -- [X] T046 [P] 在 `CONTRIBUTING.md` 与 `CONTRIBUTING.zh-CN.md` 中补充 Modulus 宪章与模块 / 宿主架构的简要说明 -- [X] T047 [P] 在 `README.md` 与 `README.zh-CN.md` 中加入对多宿主与插件化架构的简要介绍与链接 -- [X] T049 整体代码清理与重构(命名统一、命名空间与分层依赖检查、移除临时代码) -- [X] T050 运行完整测试集(`dotnet test`),修复发现的问题并记录后续 Story(如需要拆分 v2 功能) - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: 无前置依赖,优先完成,用于搭建项目骨架。 -- **Foundational (Phase 2)**: 依赖 Phase 1,完成后才具备实现任意 User Story 的基础。 -- **User Stories (Phase 3–5)**: 都依赖 Foundational 阶段完成;US1/US2/US3 可部分并行,但建议按优先级顺序交付: - - US1(垂直切片示例)→ US2(运行时模块管理)→ US3(SDK 与 AI 插件)。 -- **Polish (Final Phase)**: 在计划交付的 User Story 全部完成后执行。 - -### User Story Dependencies - -- **User Story 1 (P1)**: 仅依赖 Foundational,提供可运行的端到端示例模块,是整体架构的 MVP 验证。 -- **User Story 2 (P1)**: 依赖 US1 提供的示例模块(用于模块管理验证),也依赖 Foundational 的运行时 API。 -- **User Story 3 (P2)**: 依赖 Foundational 的 SDK 骨架与 US1/US2 的基础体验,用于强化 AI 与 SDK 的协同。 - -### Within Each User Story - -- US1: 先实现核心 Domain / Application,再实现 UI 适配与 manifest,最后完成两种宿主的集成与端到端测试。 -- US2: 先扩展运行时 API,再实现 Shell 模块的管理 UI,最后在两个宿主中集成并通过自动化测试验证。 -- US3: 先稳定 SDK 基类,再添加示例插件与测试,最后更新文档与 Quickstart。 - -### Parallel Opportunities - -- Setup 阶段中不同项目的创建任务可以并行(T002–T012)。 -- Foundational 阶段中 MediatR 集成、UI 抽象定义与 SDK 骨架实现可以在不互相阻塞的前提下并行。 -- US1 中 Blazor 与 Avalonia UI 适配实现(T026, T027)可以在核心逻辑稳定后并行推进。 -- US2 中 Blazor / Avalonia 的 Shell 集成(T036, T037)可以并行。 -- Polish 阶段中的文档与 AI 上下文更新任务(T045–T048)可以并行执行。 - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. 完成 Phase 1: Setup(项目与目录结构搭建)。 -2. 完成 Phase 2: Foundational(核心运行时 / UI 抽象 / SDK 骨架)。 -3. 完成 Phase 3: User Story 1(示例模块 + 双宿主集成)。 -4. 在两种宿主下验证示例模块端到端运行,满足 SC-001。 -5. 视需要发布首个开发者预览版本。 - -### Incremental Delivery - -1. 在 MVP 完成后,引入 User Story 2(模块启用 / 禁用 / 重新加载)并验证稳定性。 -2. 在核心稳定后引入 User Story 3(SDK 与 AI 插件),为后续插件生态奠定基础。 -3. 每完成一个 Story,即可单独进行演示与反馈收集。 - -### Parallel Team Strategy - -在多人协作场景下: - -1. 团队共同完成 Setup 与 Foundational 阶段。 -2. Foundational 完成后: - - 开发者 A 负责 US1(示例模块与双宿主集成); - - 开发者 B 负责 US2(运行时模块管理与 Shell 集成); - - 开发者 C 负责 US3(SDK 与示例插件 + 文档)。 -3. 通过统一的测试与文档收敛,确保三个 Story 在合并后仍然符合宪章与架构约束。 - - diff --git a/src/Hosts/Modulus.Host.Avalonia/App.axaml.cs b/src/Hosts/Modulus.Host.Avalonia/App.axaml.cs index aa94403..db9c503 100644 --- a/src/Hosts/Modulus.Host.Avalonia/App.axaml.cs +++ b/src/Hosts/Modulus.Host.Avalonia/App.axaml.cs @@ -4,14 +4,17 @@ using Avalonia.Styling; using Modulus.Core; using Modulus.Core.Data; +using Modulus.Core.Installation; using Modulus.Core.Runtime; using Modulus.Host.Avalonia.Services; using Modulus.Host.Avalonia.Shell.Services; using Modulus.Host.Avalonia.Shell.ViewModels; using Modulus.Host.Avalonia.Shell.Views; +using Modulus.Infrastructure.Data.Repositories; using Modulus.Sdk; using Modulus.UI.Abstractions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System; @@ -20,7 +23,7 @@ namespace Modulus.Host.Avalonia; -public class AvaloniaHostModule : ModuleBase +public class AvaloniaHostModule : ModulusComponent { public override void ConfigureServices(IModuleLifecycleContext context) { @@ -39,6 +42,16 @@ public override void ConfigureServices(IModuleLifecycleContext context) context.Services.AddTransient(); context.Services.AddTransient(); } + + public override Task OnApplicationInitializationAsync(IModuleInitializationContext context, CancellationToken cancellationToken = default) + { + // Register view mappings (menus come from database - full database-driven approach) + var viewRegistry = context.ServiceProvider.GetRequiredService(); + viewRegistry.Register(); + viewRegistry.Register(); + + return Task.CompletedTask; + } } public partial class App : Application @@ -63,42 +76,60 @@ public override void OnFrameworkInitializationCompleted() // Add Logging services.AddLogging(); - // Module Providers + // Module Providers - load from Modules/ directory var providers = new System.Collections.Generic.List(); - #if DEBUG +#if DEBUG + // Development: Load from artifacts/ (populated by nuke build-module) var solutionRoot = FindSolutionRoot(AppContext.BaseDirectory); if (solutionRoot != null) { - providers.Add(new DevelopmentModuleScanningProvider(solutionRoot, HostType.Avalonia, NullLogger.Instance)); + var artifactsModules = Path.Combine(solutionRoot, "artifacts", "Modulus.Host.Avalonia", "Modules"); + if (Directory.Exists(artifactsModules)) + { + // User modules from artifacts - NOT system modules + providers.Add(new DirectoryModuleProvider(artifactsModules, NullLogger.Instance, isSystem: false)); + } } - - var outputModules = Path.Combine(solutionRoot ?? AppContext.BaseDirectory, "_output", "modules"); - if (Directory.Exists(outputModules)) - { - providers.Add(new DirectoryModuleProvider(outputModules, NullLogger.Instance, isSystem: true)); - } - #endif - +#else + // Production: Load from {AppBaseDir}/Modules/ var appModules = Path.Combine(AppContext.BaseDirectory, "Modules"); if (Directory.Exists(appModules)) { providers.Add(new DirectoryModuleProvider(appModules, NullLogger.Instance, isSystem: true)); } +#endif + // User-installed modules (for runtime installation) var userModules = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Modulus", "Modules"); if (Directory.Exists(userModules)) { providers.Add(new DirectoryModuleProvider(userModules, NullLogger.Instance, isSystem: false)); } - // Database - var dbPath = DatabaseServiceExtensions.GetDefaultDatabasePath(); + // Configuration + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + + // Database (configurable name; defaults to framework/solution name) + var dbName = configuration["Modulus:DatabaseName"] ?? "Modulus"; + var dbPath = DatabaseServiceExtensions.GetDefaultDatabasePath(dbName); services.AddModulusDatabase(dbPath); + // Repositories & installers (needed at runtime for menu registration) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Bootstrap Modulus var appTask = Task.Run(async () => - await ModulusApplicationFactory.CreateAsync(services, providers, HostType.Avalonia) + await ModulusApplicationFactory.CreateAsync(services, providers, HostType.Avalonia, dbPath) ); _modulusApp = appTask.GetAwaiter().GetResult(); @@ -115,21 +146,27 @@ await ModulusApplicationFactory.CreateAsync(services, provid var database = Services.GetRequiredService(); database.InitializeAsync().GetAwaiter().GetResult(); + // Seed Host module and menus to database (full database-driven approach) + using (var scope = Services.CreateScope()) + { + var hostSeeder = scope.ServiceProvider.GetRequiredService(); + hostSeeder.SeedAsync( + HostType.Avalonia, + typeof(ModuleListViewModel).FullName!, + typeof(SettingsViewModel).FullName! + ).GetAwaiter().GetResult(); + } + // Initialize Theme Service (load saved theme) var themeService = Services.GetRequiredService() as AvaloniaThemeService; themeService?.InitializeAsync().GetAwaiter().GetResult(); - // Register Shell Views + // Register Shell Views (view mappings, menus come from database) var viewRegistry = Services.GetRequiredService(); viewRegistry.Register(); viewRegistry.Register(); - // Register Shell Menu Items - var menuRegistry = Services.GetRequiredService(); - menuRegistry.Register(new MenuItem("Modules", "Modules", IconKind.AppsAddIn, typeof(ModuleListViewModel).FullName!, MenuLocation.Main, 0)); - menuRegistry.Register(new MenuItem("Settings", "Settings", IconKind.Settings, typeof(SettingsViewModel).FullName!, MenuLocation.Bottom, 100)); - - // Initialize Modules + // Initialize Modules (loads menus from database into IMenuRegistry) _modulusApp.InitializeAsync().GetAwaiter().GetResult(); // Create and set ShellViewModel @@ -153,18 +190,20 @@ public void ToggleTheme() var current = RequestedThemeVariant; RequestedThemeVariant = current == ThemeVariant.Dark ? ThemeVariant.Light : ThemeVariant.Dark; } - + +#if DEBUG private static string? FindSolutionRoot(string startPath) { - var current = new DirectoryInfo(startPath); - while (current != null) + var dir = new DirectoryInfo(startPath); + while (dir != null) { - if (File.Exists(Path.Combine(current.FullName, "Modulus.sln"))) + if (File.Exists(Path.Combine(dir.FullName, "Modulus.sln"))) { - return current.FullName; + return dir.FullName; } - current = current.Parent; + dir = dir.Parent; } return null; } +#endif } diff --git a/src/Hosts/Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj b/src/Hosts/Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj index 3125475..eb6315f 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj +++ b/src/Hosts/Modulus.Host.Avalonia/Modulus.Host.Avalonia.csproj @@ -32,10 +32,18 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + @@ -48,4 +56,8 @@ + + + + diff --git a/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaNavigationService.cs b/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaNavigationService.cs index d0936eb..d11dbca 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaNavigationService.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaNavigationService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Modulus.Core.Runtime; using Modulus.UI.Abstractions; namespace Modulus.Host.Avalonia.Services; @@ -16,6 +17,7 @@ public class AvaloniaNavigationService : INavigationService private readonly IServiceProvider _serviceProvider; private readonly IUIFactory _uiFactory; private readonly IMenuRegistry _menuRegistry; + private readonly RuntimeContext _runtimeContext; private readonly List _guards = new(); private readonly ConcurrentDictionary _singletonViewModels = new(); private readonly ConcurrentDictionary _singletonViews = new(); @@ -37,11 +39,13 @@ public class AvaloniaNavigationService : INavigationService public AvaloniaNavigationService( IServiceProvider serviceProvider, IUIFactory uiFactory, - IMenuRegistry menuRegistry) + IMenuRegistry menuRegistry, + RuntimeContext runtimeContext) { _serviceProvider = serviceProvider; _uiFactory = uiFactory; _menuRegistry = menuRegistry; + _runtimeContext = runtimeContext; } public async Task NavigateToAsync(string navigationKey, NavigationOptions? options = null) @@ -199,7 +203,7 @@ private async Task EvaluateGuardsAsync(NavigationContext context) return vmType; } - // Search loaded assemblies + // Search host assemblies vmType = AppDomain.CurrentDomain.GetAssemblies() .SelectMany(a => { @@ -208,7 +212,54 @@ private async Task EvaluateGuardsAsync(NavigationContext context) }) .FirstOrDefault(t => t.FullName == navigationKey || t.Name == navigationKey); - return vmType; + if (vmType != null) + { + return vmType; + } + + // Search module assemblies (loaded in separate AssemblyLoadContexts) + foreach (var runtimeModule in _runtimeContext.RuntimeModules) + { + // Also search RuntimeModuleHandle assemblies (more complete list) + if (_runtimeContext.TryGetModuleHandle(runtimeModule.Descriptor.Id, out var handle) && handle != null) + { + foreach (var assembly in handle.Assemblies) + { + try + { + vmType = assembly.GetTypes() + .FirstOrDefault(t => t.FullName == navigationKey || t.Name == navigationKey); + if (vmType != null) + { + return vmType; + } + } + catch + { + // Skip assemblies that fail to enumerate types + } + } + } + // Fallback to LoadContext.Assemblies + foreach (var assembly in runtimeModule.LoadContext.Assemblies) + { + try + { + vmType = assembly.GetTypes() + .FirstOrDefault(t => t.FullName == navigationKey || t.Name == navigationKey); + if (vmType != null) + { + return vmType; + } + } + catch + { + // Skip assemblies that fail to enumerate types + } + } + } + + return null; } private object? GetOrCreateViewModel(Type vmType, string navigationKey, PageInstanceMode instanceMode, bool forceNew) @@ -225,24 +276,40 @@ private async Task EvaluateGuardsAsync(NavigationContext context) private object? CreateViewModel(Type vmType) { - // Try DI first - var vm = _serviceProvider.GetService(vmType); - if (vm != null) - { - return vm; - } - - // Fall back to ActivatorUtilities try { + var moduleProvider = ResolveModuleServiceProvider(vmType); + + if (moduleProvider != null) + { + var moduleVm = moduleProvider.GetService(vmType) ?? ActivatorUtilities.CreateInstance(moduleProvider, vmType); + if (moduleVm != null) + { + return moduleVm; + } + } + + var vm = _serviceProvider.GetService(vmType); + if (vm != null) + { + return vm; + } + + // Fall back to ActivatorUtilities return ActivatorUtilities.CreateInstance(_serviceProvider, vmType); } - catch + catch (Exception ex) { return null; } } + private IServiceProvider? ResolveModuleServiceProvider(Type vmType) + { + var handle = _runtimeContext.ModuleHandles.FirstOrDefault(h => h.Assemblies.Any(a => a == vmType.Assembly)); + return handle?.CompositeServiceProvider; + } + private static string ExtractDisplayName(string navigationKey) { var name = navigationKey.Split('.').LastOrDefault() ?? navigationKey; diff --git a/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaUIFactory.cs b/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaUIFactory.cs index 63f3a15..48e72ae 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaUIFactory.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Services/AvaloniaUIFactory.cs @@ -23,23 +23,38 @@ public AvaloniaUIFactory(RuntimeContext runtimeContext, IServiceProvider service public object CreateView(object viewModel) { var vmType = viewModel.GetType(); - // Console.WriteLine($"CreateView for {vmType.Name}"); // 1. Try Registry var registeredViewType = _viewRegistry.GetViewType(vmType); if (registeredViewType != null) { - // Console.WriteLine($"Found registered view {registeredViewType.Name}"); return CreateViewInstance(registeredViewType, viewModel); } - else + + // 2. Try RuntimeModuleHandle assemblies (more reliable) + var viewName = vmType.Name.Replace("ViewModel", "View"); + foreach (var module in _runtimeContext.RuntimeModules) { - // Console.WriteLine("No registered view found."); + if (module.State != ModuleState.Active && module.State != ModuleState.Loaded) continue; + + if (_runtimeContext.TryGetModuleHandle(module.Descriptor.Id, out var handle) && handle != null) + { + foreach (var asm in handle.Assemblies) + { + Type? type = null; + try { type = asm.GetTypes().FirstOrDefault(t => t.Name == viewName); } + catch { continue; } + + if (type != null && typeof(Control).IsAssignableFrom(type)) + { + _viewRegistry.Register(vmType, type); + return CreateViewInstance(type, viewModel); + } + } + } } - // 2. Try Scan (Fallback) - var viewName = vmType.Name.Replace("ViewModel", "View"); - // Console.WriteLine($"Scanning for {viewName}..."); + // 3. Try LoadContext.Assemblies (Fallback) foreach (var module in _runtimeContext.RuntimeModules) { diff --git a/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs b/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs index f4d4357..2c4ebba 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Shell/Services/MenuRegistry.cs @@ -7,16 +7,30 @@ namespace Modulus.Host.Avalonia.Shell.Services; public class MenuRegistry : IMenuRegistry { - private readonly ConcurrentBag _items = new(); + private readonly ConcurrentDictionary _items = new(StringComparer.OrdinalIgnoreCase); public void Register(MenuItem item) { - _items.Add(item); + _items[item.Id] = item; + } + + public void Unregister(string id) + { + _items.TryRemove(id, out _); + } + + public void UnregisterModuleItems(string moduleId) + { + var itemsToRemove = _items.Values.Where(i => i.ModuleId == moduleId).Select(i => i.Id).ToList(); + foreach (var id in itemsToRemove) + { + _items.TryRemove(id, out _); + } } public IEnumerable GetItems(MenuLocation location) { - return _items.Where(i => i.Location == location).OrderBy(i => i.Order); + return _items.Values.Where(i => i.Location == location).OrderBy(i => i.Order); } } diff --git a/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ModuleListViewModel.cs b/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ModuleListViewModel.cs index 9c23c70..df42b7b 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ModuleListViewModel.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ModuleListViewModel.cs @@ -1,12 +1,23 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Modulus.Core.Installation; +using Modulus.Core.Manifest; using Modulus.Core.Runtime; +using Modulus.Infrastructure.Data.Models; +using Modulus.Infrastructure.Data.Repositories; using Modulus.UI.Abstractions; +using Modulus.UI.Abstractions.Messages; + +// Alias to avoid ambiguity +using DataModuleState = Modulus.Infrastructure.Data.Models.ModuleState; +using RuntimeModuleState = Modulus.Core.Runtime.ModuleState; namespace Modulus.Host.Avalonia.Shell.ViewModels; @@ -14,81 +25,261 @@ public partial class ModuleListViewModel : ViewModelBase { private readonly RuntimeContext _runtimeContext; private readonly IModuleLoader _moduleLoader; - private readonly IEnumerable _moduleProviders; + private readonly IModuleRepository _moduleRepository; + private readonly IMenuRepository _menuRepository; + private readonly IMenuRegistry _menuRegistry; + private readonly IModuleInstallerService _moduleInstaller; private readonly INotificationService? _notificationService; public ObservableCollection Modules { get; } = new(); + [ObservableProperty] + private string _importPath = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredModules))] + [NotifyPropertyChangedFor(nameof(EnabledModules))] + [NotifyPropertyChangedFor(nameof(DisabledModules))] + private string _searchText = string.Empty; + + [ObservableProperty] + private ModuleViewModel? _selectedModule; + + [ObservableProperty] + private string _selectedModuleDetails = string.Empty; + + public List FilteredModules => + (string.IsNullOrWhiteSpace(SearchText) + ? Modules + : Modules.Where(m => m.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + public List EnabledModules => FilteredModules.Where(m => m.IsEnabled).ToList(); + public List DisabledModules => FilteredModules.Where(m => !m.IsEnabled).ToList(); + public ModuleListViewModel( RuntimeContext runtimeContext, IModuleLoader moduleLoader, - IEnumerable moduleProviders, + IModuleRepository moduleRepository, + IMenuRepository menuRepository, + IMenuRegistry menuRegistry, + IModuleInstallerService moduleInstaller, INotificationService? notificationService = null) { _runtimeContext = runtimeContext; _moduleLoader = moduleLoader; - _moduleProviders = moduleProviders; + _moduleRepository = moduleRepository; + _menuRepository = menuRepository; + _menuRegistry = menuRegistry; + _moduleInstaller = moduleInstaller; _notificationService = notificationService; Title = "Module Management"; _ = RefreshModulesAsync(); } - [RelayCommand] - private async Task RefreshModulesAsync() + partial void OnSelectedModuleChanged(ModuleViewModel? value) { - Modules.Clear(); - - var loadedModules = _runtimeContext.RuntimeModules.ToDictionary(m => m.Descriptor.Id); + if (value != null) + { + _ = LoadModuleDetailsAsync(value); + } + else + { + SelectedModuleDetails = string.Empty; + } + } - foreach (var provider in _moduleProviders) + private async Task LoadModuleDetailsAsync(ModuleViewModel module) + { + SelectedModuleDetails = "Loading..."; + + try { - var paths = await provider.GetModulePackagesAsync(); - foreach (var path in paths) + var manifestPath = Path.GetFullPath(module.Entity.Path); + var dir = Path.GetDirectoryName(manifestPath); + + // 1. Try README.md + if (dir != null) { - var descriptor = await _moduleLoader.GetDescriptorAsync(path); - if (descriptor == null) continue; - - if (loadedModules.TryGetValue(descriptor.Id, out var loadedModule)) + var readmePath = Path.Combine(dir, "README.md"); + if (File.Exists(readmePath)) { - Modules.Add(new ModuleViewModel(loadedModule)); - loadedModules.Remove(descriptor.Id); + SelectedModuleDetails = await File.ReadAllTextAsync(readmePath); + return; } - else + } + + // 2. Fallback to Manifest Description + if (File.Exists(manifestPath)) + { + var manifest = await ManifestReader.ReadFromFileAsync(manifestPath); + if (!string.IsNullOrWhiteSpace(manifest?.Description)) { - Modules.Add(new ModuleViewModel(descriptor, path, ModuleState.Unloaded)); + SelectedModuleDetails = manifest.Description; + return; } } } + catch { /* Ignore file access errors */ } + + SelectedModuleDetails = "No description provided."; + } - foreach (var remaining in loadedModules.Values) + [RelayCommand] + private async Task RefreshModulesAsync() + { + Modules.Clear(); + + var dbModules = await _moduleRepository.GetAllAsync(); + + foreach (var dbModule in dbModules) + { + // Skip built-in host modules - they shouldn't appear in the installed modules list + if (dbModule.IsSystem && dbModule.Path == "built-in") + { + continue; + } + + _runtimeContext.TryGetModule(dbModule.Id, out var runtimeModule); + Modules.Add(new ModuleViewModel(dbModule, runtimeModule)); + } + + OnPropertyChanged(nameof(FilteredModules)); + OnPropertyChanged(nameof(EnabledModules)); + OnPropertyChanged(nameof(DisabledModules)); + + if (SelectedModule == null && Modules.Any()) + { + SelectedModule = Modules.First(); + } + else if (SelectedModule != null) { - Modules.Add(new ModuleViewModel(remaining)); + // Reload details for currently selected module + _ = LoadModuleDetailsAsync(SelectedModule); } } [RelayCommand] private async Task ToggleModuleAsync(ModuleViewModel moduleVm) { - if (moduleVm == null) return; + if (moduleVm == null || moduleVm.IsSystem) return; - try + if (moduleVm.IsEnabled) + { + // Disable: Unload if loaded, then mark as disabled + if (moduleVm.IsLoaded) + { + await _moduleLoader.UnloadAsync(moduleVm.Id); + } + + await _moduleRepository.UpdateStateAsync(moduleVm.Id, DataModuleState.Disabled); + + // Unregister menus from MenuRegistry + _menuRegistry.UnregisterModuleItems(moduleVm.Id); + + // Notify ShellViewModel to remove menus (incremental) + WeakReferenceMessenger.Default.Send(new MenuItemsRemovedMessage(moduleVm.Id)); + } + else + { + // Enable + if (moduleVm.Entity.State == DataModuleState.MissingFiles) + { + _notificationService?.ShowErrorAsync("Error", "Cannot enable module with missing files."); + return; + } + + await _moduleRepository.UpdateStateAsync(moduleVm.Id, DataModuleState.Ready); + + // Resolve absolute path + var manifestPath = Path.GetFullPath(moduleVm.Entity.Path); + var packagePath = Path.GetDirectoryName(manifestPath); + + if (packagePath != null) + { + await _moduleLoader.LoadAsync(packagePath, moduleVm.IsSystem); + } + + // Register menus from database and notify ShellViewModel (incremental) + var addedMenus = await RegisterModuleMenusAsync(moduleVm.Id); + if (addedMenus.Count > 0) + { + WeakReferenceMessenger.Default.Send(new MenuItemsAddedMessage(addedMenus)); + } + } + + await RefreshModulesAsync(); + + OnPropertyChanged(nameof(EnabledModules)); + OnPropertyChanged(nameof(DisabledModules)); + } + + private async Task> RegisterModuleMenusAsync(string moduleId) + { + var menus = await _menuRepository.GetByModuleIdAsync(moduleId); + var addedItems = new List(); + + foreach (var menu in menus) { - if (moduleVm.State == ModuleState.Active || moduleVm.State == ModuleState.Loaded) - { - await _moduleLoader.UnloadAsync(moduleVm.Id); - } - else if (moduleVm.State == ModuleState.Unloaded) - { - if (string.IsNullOrEmpty(moduleVm.PackagePath)) - { - _notificationService?.ShowErrorAsync("Error", "Cannot load module: package path unknown."); - return; - } - await _moduleLoader.LoadAsync(moduleVm.PackagePath); - } - - await RefreshModulesAsync(); + var iconKind = IconKind.Grid; + if (Enum.TryParse(menu.Icon, true, out var parsedIcon)) + { + iconKind = parsedIcon; + } + + // Use NavigationKey for Avalonia (ViewModelType fullname) + var navigationKey = menu.Route ?? menu.Id; + + var item = new MenuItem( + menu.Id, + menu.DisplayName, + iconKind, + navigationKey, + menu.Location, + menu.Order + ); + item.ModuleId = menu.ModuleId; + + _menuRegistry.Register(item); + addedItems.Add(item); + } + + return addedItems; + } + + [RelayCommand] + private async Task RemoveModuleAsync(ModuleViewModel moduleVm) + { + if (moduleVm == null || moduleVm.IsSystem) return; + + try + { + if (moduleVm.IsLoaded) + { + await _moduleLoader.UnloadAsync(moduleVm.Id); + } + + await _moduleRepository.DeleteAsync(moduleVm.Id); + // Optionally clean files? For now, we just remove from DB as per task "Remove (Delete DB record + Clean folder)" + // Clean folder logic: + try + { + var manifestPath = Path.GetFullPath(moduleVm.Entity.Path); + var dir = Path.GetDirectoryName(manifestPath); + if (dir != null && Directory.Exists(dir)) + { + // Basic safety: don't delete root or system folders + // TODO: Improve safety + Directory.Delete(dir, true); + } + } + catch (Exception ex) + { + _notificationService?.ShowErrorAsync("Warning", $"Module removed from DB but failed to delete files: {ex.Message}"); + } + + await RefreshModulesAsync(); } catch (Exception ex) { @@ -97,64 +288,97 @@ private async Task ToggleModuleAsync(ModuleViewModel moduleVm) } [RelayCommand] - private async Task ReloadModuleAsync(ModuleViewModel moduleVm) + private async Task ImportModuleAsync() { - if (moduleVm == null) return; - - try - { - await _moduleLoader.ReloadAsync(moduleVm.Id); - await RefreshModulesAsync(); - } - catch (Exception ex) - { - _notificationService?.ShowErrorAsync("Error", ex.Message); - } + if (string.IsNullOrWhiteSpace(ImportPath)) return; + + // ImportPath could be a directory or manifest.json + var path = ImportPath; + if (File.Exists(path) && Path.GetFileName(path) == "manifest.json") + { + // ok + } + else if (Directory.Exists(path)) + { + path = Path.Combine(path, "manifest.json"); + } + else + { + _notificationService?.ShowErrorAsync("Error", "Invalid path."); + return; + } + + try + { + await _moduleInstaller.RegisterDevelopmentModuleAsync(path); + ImportPath = string.Empty; + await RefreshModulesAsync(); + _notificationService?.ShowErrorAsync("Success", "Module imported."); // Using ShowError as ShowInfo might not exist + } + catch (Exception ex) + { + _notificationService?.ShowErrorAsync("Error", ex.Message); + } } } public partial class ModuleViewModel : ObservableObject { - private readonly RuntimeModule? _runtimeModule; - private readonly ModuleDescriptor _descriptor; - private readonly string _packagePath; - - [ObservableProperty] - private ModuleState _state; - - public string Id => _descriptor.Id; - public string DisplayName => _descriptor.DisplayName; - public string Description => _descriptor.Description; - public string Version => _descriptor.Version; - public string PackagePath => _packagePath; - public bool IsSystem => _runtimeModule?.IsSystem ?? false; - - public string StatusColor => State switch - { - ModuleState.Active => "#4CAF50", - ModuleState.Loaded => "#2196F3", - ModuleState.Error => "#F44336", - _ => "#9E9E9E" - }; + public ModuleEntity Entity { get; } + public RuntimeModule? RuntimeModule { get; } - public ModuleViewModel(RuntimeModule module) + public ModuleViewModel(ModuleEntity entity, RuntimeModule? runtimeModule) { - _runtimeModule = module; - _descriptor = module.Descriptor; - _packagePath = module.PackagePath; - State = module.State; + Entity = entity; + RuntimeModule = runtimeModule; } - public ModuleViewModel(ModuleDescriptor descriptor, string packagePath, ModuleState state) + public string Id => Entity.Id; + public string Name => Entity.Name; + public string Version => Entity.Version; + public string Description => Entity.Description ?? "No description"; + public string Author => Entity.Author ?? "AGIBuild"; + public bool IsSystem => Entity.IsSystem; + public string MenuLocation => Entity.MenuLocation.ToString(); + + /// + /// Whether the module is enabled (based on database state, not runtime). + /// + public bool IsEnabled => Entity.IsEnabled && Entity.State != DataModuleState.Disabled; + + /// + /// Whether the module is actually loaded and running in the runtime. + /// + public bool IsLoaded => RuntimeModule?.State == RuntimeModuleState.Active; + + // Status Logic + public string StatusText { - _runtimeModule = null; - _descriptor = descriptor; - _packagePath = packagePath; - State = state; + get + { + if (Entity.State == DataModuleState.MissingFiles) return "Missing Files"; + if (Entity.State == DataModuleState.Disabled || !Entity.IsEnabled) return "Disabled"; + if (IsLoaded) return "Running"; + return "Ready"; // Enabled but not yet loaded + } } - public bool IsLoaded => State == ModuleState.Active || State == ModuleState.Loaded || State == ModuleState.Error; - public bool IsUnloaded => State == ModuleState.Unloaded; - public bool CanUnload => IsLoaded && !IsSystem; -} + public string StatusColor => StatusText switch + { + "Running" => "#4CAF50", // Green + "Ready" => "#2196F3", // Blue + "Disabled" => "#9E9E9E", // Grey + "Missing Files" => "#FFC107", // Amber/Yellow + _ => "#9E9E9E" + }; + /// + /// Whether the toggle button should be shown (non-system modules can be toggled). + /// + public bool ShowToggle => !IsSystem; + + /// + /// Whether this module can be removed (only non-system modules). + /// + public bool CanRemove => !IsSystem; +} diff --git a/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs b/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs index 8473aa0..1c8c2c0 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs +++ b/src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ShellViewModel.cs @@ -1,16 +1,20 @@ +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Microsoft.Extensions.DependencyInjection; +using CommunityToolkit.Mvvm.Messaging; using Modulus.Host.Avalonia.Services; using Modulus.UI.Abstractions; -using System; +using Modulus.UI.Abstractions.Messages; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; namespace Modulus.Host.Avalonia.Shell.ViewModels; -public partial class ShellViewModel : ViewModelBase +public partial class ShellViewModel : ViewModelBase, + IRecipient, + IRecipient, + IRecipient { private readonly IMenuRegistry _menuRegistry; private readonly INavigationService _navigationService; @@ -57,8 +61,62 @@ public ShellViewModel( _avaloniaNavService.OnViewChanged = OnNavigationViewChanged; } + // Subscribe to menu messages + WeakReferenceMessenger.Default.RegisterAll(this); + RefreshMenu(); } + + /// + /// Handle full menu refresh message - reload all menus from registry. + /// + public void Receive(MenuRefreshMessage message) + { + Dispatcher.UIThread.Post(RefreshMenu); + } + + /// + /// Handle incremental menu addition - add items without losing selection. + /// + public void Receive(MenuItemsAddedMessage message) + { + Dispatcher.UIThread.Post(() => + { + foreach (var item in message.Items) + { + var collection = item.Location == MenuLocation.Main ? MainMenuItems : BottomMenuItems; + + // Avoid duplicates + if (collection.All(m => m.Id != item.Id)) + { + // Insert in order + var index = collection.Count(m => m.Order < item.Order); + collection.Insert(index, item); + } + } + }); + } + + /// + /// Handle incremental menu removal - remove items without losing selection. + /// + public void Receive(MenuItemsRemovedMessage message) + { + Dispatcher.UIThread.Post(() => + { + var mainToRemove = MainMenuItems.Where(m => m.ModuleId == message.ModuleId).ToList(); + foreach (var item in mainToRemove) + { + MainMenuItems.Remove(item); + } + + var bottomToRemove = BottomMenuItems.Where(m => m.ModuleId == message.ModuleId).ToList(); + foreach (var item in bottomToRemove) + { + BottomMenuItems.Remove(item); + } + }); + } partial void OnIsNavCollapsedChanged(bool value) { diff --git a/src/Hosts/Modulus.Host.Avalonia/Shell/Views/ModuleListView.axaml b/src/Hosts/Modulus.Host.Avalonia/Shell/Views/ModuleListView.axaml index af62afd..1cf2a52 100644 --- a/src/Hosts/Modulus.Host.Avalonia/Shell/Views/ModuleListView.axaml +++ b/src/Hosts/Modulus.Host.Avalonia/Shell/Views/ModuleListView.axaml @@ -3,104 +3,205 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:Modulus.Host.Avalonia.Shell.ViewModels" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + xmlns:md="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia" + mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600" x:Class="Modulus.Host.Avalonia.Shell.Views.ModuleListView" x:DataType="vm:ModuleListViewModel"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -