diff --git a/.github/CODE_SCANNING.md b/.github/CODE_SCANNING.md index 48db78e6..66d29c9b 100644 --- a/.github/CODE_SCANNING.md +++ b/.github/CODE_SCANNING.md @@ -59,12 +59,12 @@ Compile-time enforcement of architectural patterns. Analyzers run during build a | ID | Name | Description | Source | |----|------|-------------|--------| -| PPDS001 | NoDirectFileIoInUi | UI layer using File.Read/Write directly | ADR-0024 | -| PPDS002 | NoConsoleInServices | Service using Console.WriteLine | ADR-0015 | -| PPDS003 | NoUiFrameworkInServices | Service referencing Spectre/Terminal.Gui | ADR-0025 | -| PPDS004 | UseStructuredExceptions | Service throwing raw Exception | ADR-0026 | -| PPDS005 | NoSdkInPresentation | CLI command calling ServiceClient directly | ADR-0015 | -| PPDS007 | PoolClientInParallel | Pool client acquired outside parallel loop | ADR-0002/0005 | +| PPDS001 | NoDirectFileIoInUi | UI layer using File.Read/Write directly | Architecture | +| PPDS002 | NoConsoleInServices | Service using Console.WriteLine | Architecture | +| PPDS003 | NoUiFrameworkInServices | Service referencing Spectre/Terminal.Gui | Architecture | +| PPDS004 | UseStructuredExceptions | Service throwing raw Exception | Architecture | +| PPDS005 | NoSdkInPresentation | CLI command calling ServiceClient directly | Architecture | +| PPDS007 | PoolClientInParallel | Pool client acquired outside parallel loop | Architecture | | PPDS008 | UseBulkOperations | Loop with single Delete/Update calls | Gemini PR#243 | | PPDS011 | PropagateCancellation | Async method not passing CancellationToken | Gemini PR#242 | @@ -116,19 +116,8 @@ If a finding reveals a real bug: 2. Reference the analyzer rule (e.g., "Found by PPDS012") 3. Include the file and line number -## Architecture (ADRs) - -Bot instructions reference these Architecture Decision Records: - -| ADR | Summary | -|-----|---------| -| [0015](../docs/adr/0015_APPLICATION_SERVICE_LAYER.md) | Application Services for CLI/TUI/Daemon | -| [0024](../docs/adr/0024_SHARED_LOCAL_STATE.md) | Shared local state architecture | -| [0025](../docs/adr/0025_UI_AGNOSTIC_PROGRESS.md) | UI-agnostic progress reporting | -| [0026](../docs/adr/0026_STRUCTURED_ERROR_MODEL.md) | Structured error model | - ## Related Issues - [#231](https://github.com/joshsmithxrm/power-platform-developer-suite/issues/231) - Tune code scanning tools to reduce noise -- [#232](https://github.com/joshsmithxrm/power-platform-developer-suite/issues/232) - ADR-0024 (style) - Prefer foreach over LINQ +- [#232](https://github.com/joshsmithxrm/power-platform-developer-suite/issues/232) - Prefer foreach over LINQ - [#246](https://github.com/joshsmithxrm/power-platform-developer-suite/issues/246) - Analyzer triage process and PPDS013 refinement diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 715fa843..3188875b 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -18,7 +18,7 @@ queries: query-filters: - exclude: id: - # LINQ style suggestions - conflicts with ADR-0024 (prefer explicit foreach) + # LINQ style suggestions - prefer explicit foreach - cs/linq/missed-where - cs/linq/missed-select # Style preferences - not quality issues diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fc193808..d8c06265 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,20 +10,20 @@ This file guides GitHub Copilot's code review behavior for the PPDS repository. - Application Services own all business logic - Services are UI-agnostic - no `Console.WriteLine`, no Spectre/Terminal.Gui references -### File I/O (ADR-0024: Shared Local State) +### File I/O - UIs never read/write files directly - All file access through Application Services - WRONG: `File.ReadAllText` in command handler - CORRECT: `await _profileService.GetProfilesAsync()` -### Progress Reporting (ADR-0025: UI-Agnostic Progress) +### Progress Reporting - Services accept `IProgressReporter`, not write to console - UIs implement adapters for their display medium - Services return data, presentation layers format it -### Error Handling (ADR-0026: Structured Error Model) +### Error Handling - Services throw `PpdsException` with `ErrorCode` and `UserMessage` - Never expose technical details (GUIDs, stack traces) in `UserMessage` diff --git a/.gitignore b/.gitignore index 37d27634..6115c3b4 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,4 @@ states.json # Git worktrees .worktrees/ +.muse/ diff --git a/CLAUDE.md b/CLAUDE.md index 33a4d3a0..43eee25c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,17 +6,17 @@ SDK, CLI, TUI, VS Code Extension, and MCP server for Power Platform development. - Regenerate `PPDS.Plugins.snk` - breaks strong naming for all assemblies - Create new `ServiceClient` per request - 42,000x slower than pool; use `IDataverseConnectionPool` -- Hold single pooled client for multiple queries - defeats pool parallelism; see ADR-0002 +- Hold single pooled client for multiple queries - defeats pool parallelism - Write CLI status messages to stdout - use `Console.Error.WriteLine`; stdout is for data - Throw raw exceptions from Application Services - wrap in `PpdsException` with ErrorCode ## ALWAYS -- Use connection pool for multi-request scenarios - see ADR-0002, ADR-0005 +- Use connection pool for multi-request scenarios - Use bulk APIs (`CreateMultiple`, `UpdateMultiple`) - 5x faster than `ExecuteMultiple` -- Use Application Services for all persistent state - single code path for CLI/TUI/RPC (ADR-0024) -- Accept `IProgressReporter` for operations >1 second - all UIs need feedback (ADR-0025) -- Include ErrorCode in `PpdsException` - enables programmatic handling (ADR-0026) +- Use Application Services for all persistent state - single code path for CLI/TUI/RPC +- Accept `IProgressReporter` for operations >1 second - all UIs need feedback +- Include ErrorCode in `PpdsException` - enables programmatic handling ## Tech Stack @@ -27,10 +27,9 @@ SDK, CLI, TUI, VS Code Extension, and MCP server for Power Platform development. ## Key Files -- `src/PPDS.Cli/Services/` - Application Services (ADR-0015) +- `src/PPDS.Cli/Services/` - Application Services - `src/PPDS.Dataverse/Generated/` - Early-bound entities (DO NOT edit) -- `docs/adr/` - Architecture Decision Records -- `docs/patterns/` - Canonical code patterns +- `docs/specs/` - Feature specifications ## Commands @@ -47,9 +46,6 @@ SDK, CLI, TUI, VS Code Extension, and MCP server for Power Platform development. - Integration (live): `--filter Category=Integration` - TUI: `--filter Category=TuiUnit` -See ADR-0028 (TUI testing), ADR-0029 (full strategy). - ## Architecture TUI-first multi-interface platform. All business logic in Application Services, never in UI code. -See `docs/patterns/architecture.md` for diagrams. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41670ef6..daaead48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,10 +97,10 @@ The pre-commit hook automatically runs unit tests (~10s). | Pattern | Reference | |---------|-----------| -| Connection pooling | ADR-0002, `ServiceClientPool.cs` | -| Bulk operations | ADR-0005, `BulkOperationExecutor.cs` | -| CLI output | ADR-0008 | -| Application services | ADR-0015 | +| Connection pooling | `ServiceClientPool.cs` | +| Bulk operations | `BulkOperationExecutor.cs` | +| CLI output | `src/PPDS.Cli/Services/` | +| Application services | `src/PPDS.Cli/Services/` | ### What to Avoid @@ -122,7 +122,7 @@ power-platform-developer-suite/ │ └── PPDS.Mcp/ # MCP server ├── extension/ # VS Code extension ├── tests/ # Test projects -├── docs/adr/ # Architecture Decision Records +├── docs/specs/ # Feature specifications └── templates/claude/ # Claude Code integration ``` @@ -130,7 +130,7 @@ power-platform-developer-suite/ - **Questions**: Open a [Discussion](https://github.com/joshsmithxrm/power-platform-developer-suite/discussions) - **Bugs**: Open an [Issue](https://github.com/joshsmithxrm/power-platform-developer-suite/issues) -- **Architecture**: Check [ADRs](docs/adr/README.md) for design decisions +- **Architecture**: Check `docs/specs/` for design decisions ## License diff --git a/Directory.Packages.props b/Directory.Packages.props index ad51b455..7918b2a4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,7 @@ + diff --git a/README.md b/README.md index 2bc8a9e8..fecaf516 100644 --- a/README.md +++ b/README.md @@ -271,16 +271,6 @@ dotnet test --filter Category=TuiUnit --- -## Architecture Decisions - -Key design decisions are documented as ADRs in [docs/adr/](docs/adr/README.md): - -- [ADR-0002: Multi-Connection Pooling](docs/adr/0002_MULTI_CONNECTION_POOLING.md) -- [ADR-0005: DOP-Based Parallelism](docs/adr/0005_DOP_BASED_PARALLELISM.md) -- [ADR-0007: Unified CLI and Shared Authentication](docs/adr/0007_UNIFIED_CLI_AND_AUTH.md) -- [ADR-0008: CLI Output Architecture](docs/adr/0008_CLI_OUTPUT_ARCHITECTURE.md) -- [ADR-0015: Application Service Layer](docs/adr/0015_APPLICATION_SERVICE_LAYER.md) - ## Patterns - [Connection Pooling](docs/architecture/CONNECTION_POOLING_PATTERNS.md) - When and how to use connection pooling diff --git a/docs/FEATURE_ROADMAP.md b/docs/FEATURE_ROADMAP.md index 76b61466..247338f4 100644 --- a/docs/FEATURE_ROADMAP.md +++ b/docs/FEATURE_ROADMAP.md @@ -45,7 +45,7 @@ - Power Apps Admin API for connections (different from Dataverse) - Orphaned connection reference detection (port from extension) - Deployment settings sync with value preservation and deterministic sorting -- PAC-compatible format (see [ADR-0011](adr/0011_DEPLOYMENT_SETTINGS_FORMAT.md)) +- PAC-compatible format --- @@ -55,7 +55,7 @@ **Scope:** - Full filtering (25 fields, 11 operators, 8 quick filters) -- Hybrid filter approach: inline flags + filter file (see [ADR-0012](adr/0012_HYBRID_FILTER_DESIGN.md)) +- Hybrid filter approach: inline flags + filter file - Timeline correlation view - Trace level management (off/exception/all) - Export/delete operations @@ -67,7 +67,7 @@ **Design Session:** 2026-01-04 - Completed **Scope:** -- Published vs unpublished content (default: published per [ADR-0010](adr/0010_PUBLISHED_UNPUBLISHED_DEFAULT.md)) +- Published vs unpublished content (default: published, `--unpublished` flag) - Conflict detection on push (timestamp-based with hash tracking) - Efficient filtering for 60K+ resources - Hierarchical pull with `--strip-prefix` option @@ -90,17 +90,6 @@ Full plugin lifecycle: assemblies, packages, steps, images, service endpoints, w --- -## Architecture Decision Records - -| ADR | Decision | -|-----|----------| -| [ADR-0009](adr/0009_CLI_COMMAND_TAXONOMY.md) | Use `ppds plugintraces` (not `traces` or `plugins traces`) | -| [ADR-0010](adr/0010_PUBLISHED_UNPUBLISHED_DEFAULT.md) | Default to published, `--unpublished` flag | -| [ADR-0011](adr/0011_DEPLOYMENT_SETTINGS_FORMAT.md) | Use PAC-compatible deployment settings format | -| [ADR-0012](adr/0012_HYBRID_FILTER_DESIGN.md) | Hybrid filter design: inline flags + filter file | - ---- - ## Key Design Decisions ### JSON Output Strategy @@ -120,5 +109,4 @@ Every command serves Extension (JSON-RPC), Humans (tables), AI/Tooling (structur ## References -- [CLI Output Architecture (ADR-0008)](adr/0008_CLI_OUTPUT_ARCHITECTURE.md) - [Extension CLI Migration Issues](https://github.com/joshsmithxrm/power-platform-developer-suite/issues?q=is%3Aissue+label%3Aepic%3Acli-daemon) diff --git a/docs/adr/0017_GIT_BRANCHING_STRATEGY.md b/docs/adr/0017_GIT_BRANCHING_STRATEGY.md deleted file mode 100644 index 2a95d2ce..00000000 --- a/docs/adr/0017_GIT_BRANCHING_STRATEGY.md +++ /dev/null @@ -1,140 +0,0 @@ -# ADR-0017: Git Branching Strategy - -**Status:** Accepted -**Date:** 2026-01-07 -**Authors:** Josh, Claude - -## Context - -The power-platform-developer-suite repository uses git worktrees for parallel development. Without documented conventions: -- Branch names are inconsistent -- Worktree locations vary -- Unclear when to create worktrees vs branches -- PR workflow not standardized - -## Decision - -### Branch Naming - -| Prefix | Purpose | Example | -|--------|---------|---------| -| `feature/` | New functionality | `feature/plugin-traces` | -| `fix/` | Bug fixes | `fix/bypass-plugins` | -| `docs/` | Documentation only | `docs/file-format-policy` | -| `chore/` | Maintenance, refactoring | `chore/pre-pr-cleanup` | -| `release/` | Release preparation | `release/v1.0.0` | - -### Worktree Strategy - -Use worktrees for parallel, isolated work. Each worktree is a separate directory with its own branch. - -**Location:** `{base}/ppds-{branch-suffix}` (sibling to main `ppds` folder) - -``` -{base}/ -├── ppds/ # Main repo (main branch) -├── ppds-plugin-traces/ # feature/plugin-traces -├── ppds-tui-enhancements/ # feature/tui-enhancements -└── ppds-file-format-adr/ # docs/file-format-policy -``` - -**When to use worktrees:** -- Work spans multiple sessions -- Need to context-switch between features -- Large feature requiring isolation - -**When to use simple branches:** -- Quick fixes (< 1 hour) -- Single-session work -- No need to switch away - -### Creating Worktrees - -**For issue-driven work:** Use `/plan-work` -``` -/plan-work 123 456 -``` -- Fetches issues from GitHub -- Creates worktrees with session prompts -- Session prompt includes verification checklist and research hints -- Then use `/start-work` in the worktree to begin - -**For ad-hoc work:** Use `/create-worktree` -``` -/create-worktree "add authentication caching" -/create-worktree "fix null pointer in bulk ops" --fix -/create-worktree "update docs" --issue # also creates GitHub issue -``` -- No GitHub issue required -- Infers branch type from description -- Then enter plan mode in the worktree to establish context - -### Worktree Lifecycle - -```bash -# Option 1: Issue-driven (recommended for tracked work) -/plan-work 123 # In ppds/ orchestrator -cd ../ppds-feature-x -/start-work # Displays session context -# Enter plan mode to verify and plan - -# Option 2: Ad-hoc (for untracked work) -/create-worktree "description" # In ppds/ orchestrator -cd ../ppds-xxx -# Enter plan mode to establish context - -# Work, commit, push -git push -u origin feature/{name} -gh pr create - -# After PR merges, clean up -/prune # Or: git worktree remove ../ppds-{name} -``` - -### Plan Mode as Session Context - -Plan mode is the recommended way to establish session context in any worktree: - -**Issue-driven worktrees:** -- Session prompt created by `/plan-work` contains verification checklist -- Verification ensures issue approach is still valid for current codebase -- Plan mode researches, verifies, then creates implementation plan - -**Ad-hoc worktrees:** -- No session prompt - user describes work in plan mode -- Plan mode researches codebase and creates implementation plan -- Plan file becomes the session context - -**Why plan mode?** -- Issues can be stale - created based on older codebase state -- Plan mode verifies approach against current patterns and ADRs -- User approval before implementation prevents wasted effort - -### Main Branch Protection - -- `main` is protected; direct commits blocked -- All changes require PR with passing CI -- Squash merge preferred for clean history - -### PR Conventions - -1. **Title:** `type: description` (e.g., `feat: add plugin traces CLI`) -2. **Body:** Include "Closes #123" for linked issues -3. **CI:** All checks must pass -4. **Review:** Required for non-docs changes - -## Consequences - -### Positive -- Consistent branch naming aids navigation -- Worktrees enable true parallel development -- Clear lifecycle prevents orphaned worktrees -- Protected main ensures stability - -### Negative -- More disk space for multiple worktrees -- Must remember to clean up after merge - -### Neutral -- Existing workflows unchanged -- Optional adoption of worktree pattern diff --git a/docs/adr/README.md b/docs/adr/README.md deleted file mode 100644 index 003a63b8..00000000 --- a/docs/adr/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# Architecture Decision Records (ADRs) - -This directory contains Architecture Decision Records for PPDS. - -> **Note:** Most historical ADRs have been absorbed into specification documents in `specs/`. Each spec includes a "Design Decisions" section explaining the rationale behind key choices. Only standalone workflow decisions (like branching strategy) remain as ADRs. - -## What is an ADR? - -An ADR captures a significant architectural decision along with its context and consequences. ADRs help future maintainers understand *why* decisions were made, not just *what* was decided. - -## When to Write an ADR - -Write an ADR when: - -| Situation | Example | -|-----------|---------| -| Introducing a new pattern | Connection pooling strategy, error handling model | -| Making a cross-cutting decision | File format policy, CLI output conventions | -| Choosing between alternatives | Why bulk APIs over ExecuteMultiple | -| Defining ownership/responsibility | Which component owns elapsed time tracking | - -Don't write an ADR for: -- Bug fixes -- Implementation details that don't affect architecture -- Decisions that are easily reversible - -## ADR Format - -Use this template: - -```markdown -# ADR-NNNN: Title - -**Status:** Proposed | Accepted | Deprecated | Superseded by ADR-XXXX -**Date:** YYYY-MM-DD -**Authors:** Names - -## Context - -What is the issue? Why are we making this decision? - -## Decision - -What is the change being proposed/made? - -## Consequences - -### Positive -- Benefits of this decision - -### Negative -- Tradeoffs and costs - -### Neutral -- Side effects that are neither good nor bad - -## References - -- Related ADRs -- External documentation -``` - -## ADR Lifecycle - -### Immutability - -**ADRs are immutable once accepted.** The decision was made at a point in time with specific context. Changing the ADR retroactively hides history. - -### Evolving Decisions - -When a decision needs to change: - -1. **Create a new ADR** that supersedes the old one -2. **Update the old ADR's status** to `Superseded by ADR-XXXX` -3. **Reference the old ADR** in the new one to explain the evolution - -### Filling Gaps - -When an existing ADR has gaps (missing concerns): - -1. **Create a new ADR** addressing the gap -2. **Add a reference** in the original ADR's References section -3. **Don't modify** the original decision or context - -Example: -```markdown -## References -- ADR-0015: Application Service Layer -- ADR-0027: Operation Clock (fills gap in elapsed time ownership) -``` - -## Numbering - -ADRs are numbered sequentially: `0001`, `0002`, etc. Use the next available number. - -To find the next number: -```powershell -Get-ChildItem docs/adr/*.md | Sort-Object Name | Select-Object -Last 1 -``` - -## File Naming - -Format: `NNNN_BRIEF_DESCRIPTION.md` - -Examples: -- `0025_UI_AGNOSTIC_PROGRESS.md` -- `0026_STRUCTURED_ERROR_MODEL.md` diff --git a/docs/plans/2026-02-07-environment-config.md b/docs/plans/2026-02-07-environment-config.md new file mode 100644 index 00000000..3d659cfd --- /dev/null +++ b/docs/plans/2026-02-07-environment-config.md @@ -0,0 +1,1712 @@ +# Environment Configuration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add user-configurable environment types and colors, replacing hardcoded URL-based heuristics with a persisted config that works across CLI, TUI, and VS Code (via RPC). + +**Architecture:** New `EnvironmentConfigStore` in `PPDS.Auth` persists environment configs to `~/.ppds/environments.json`. A new `IEnvironmentConfigService` Application Service in `PPDS.Cli` provides resolution logic (user config > discovery type > URL heuristics). The TUI's `TuiThemeService` delegates to this service instead of doing its own detection. CLI gets a new `ppds env config` subcommand. + +**Tech Stack:** C# (.NET 8+), System.Text.Json, System.CommandLine, Terminal.Gui 1.19+, xUnit + +--- + +### Task 1: EnvironmentColor Enum (PPDS.Auth) + +**Files:** +- Create: `src/PPDS.Auth/Profiles/EnvironmentColor.cs` +- Test: `tests/PPDS.Cli.Tests/Services/Environment/EnvironmentConfigStoreTests.cs` + +**Step 1: Create the enum** + +```csharp +// src/PPDS.Auth/Profiles/EnvironmentColor.cs +namespace PPDS.Auth.Profiles; + +/// +/// Named colors for environment theming. +/// Maps to 16-color terminal palette (works in TUI and VS Code). +/// +public enum EnvironmentColor +{ + Red, + Green, + Yellow, + Cyan, + Blue, + Gray, + Brown, + White, + BrightRed, + BrightGreen, + BrightYellow, + BrightCyan, + BrightBlue +} +``` + +**Step 2: Commit** + +``` +git add src/PPDS.Auth/Profiles/EnvironmentColor.cs +git commit -m "feat(auth): add EnvironmentColor enum for configurable environment theming" +``` + +--- + +### Task 2: EnvironmentConfig Model (PPDS.Auth) + +**Files:** +- Create: `src/PPDS.Auth/Profiles/EnvironmentConfig.cs` + +**Step 1: Create the model** + +```csharp +// src/PPDS.Auth/Profiles/EnvironmentConfig.cs +using System.Text.Json.Serialization; + +namespace PPDS.Auth.Profiles; + +/// +/// User configuration for a specific Dataverse environment. +/// Stores label, type classification, and color override. +/// +public sealed class EnvironmentConfig +{ + /// + /// Normalized environment URL (lowercase, trailing slash). This is the key. + /// + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + /// + /// Short label for status bar and tab display (e.g., "Contoso Dev"). + /// Null means use the environment's DisplayName. + /// + [JsonPropertyName("label")] + public string? Label { get; set; } + + /// + /// Environment type classification (e.g., "Production", "Sandbox", "UAT", "Gold"). + /// Free-text string — built-in types have default colors, custom types use typeDefaults. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Explicit color override for this specific environment. + /// Takes priority over type-based color. Null means use type default. + /// + [JsonPropertyName("color")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public EnvironmentColor? Color { get; set; } + + /// + /// Normalizes a URL for use as a lookup key (lowercase, ensures trailing slash). + /// + public static string NormalizeUrl(string url) + { + var normalized = url.Trim().ToLowerInvariant(); + if (!normalized.EndsWith('/')) + normalized += '/'; + return normalized; + } +} +``` + +**Step 2: Commit** + +``` +git add src/PPDS.Auth/Profiles/EnvironmentConfig.cs +git commit -m "feat(auth): add EnvironmentConfig model with label, type, and color" +``` + +--- + +### Task 3: EnvironmentConfigCollection Model (PPDS.Auth) + +**Files:** +- Create: `src/PPDS.Auth/Profiles/EnvironmentConfigCollection.cs` + +**Step 1: Create the collection** + +This is the root object serialized to `environments.json`. + +```csharp +// src/PPDS.Auth/Profiles/EnvironmentConfigCollection.cs +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace PPDS.Auth.Profiles; + +/// +/// Root object for environments.json — holds per-environment configs and custom type defaults. +/// +public sealed class EnvironmentConfigCollection +{ + /// + /// Schema version for forward compatibility. + /// + [JsonPropertyName("version")] + public int Version { get; set; } = 1; + + /// + /// Custom type definitions with default colors. + /// Key is the type name (e.g., "UAT", "Gold"), value is the default color. + /// Built-in types (Production, Sandbox, Development, Test, Trial) do not need entries here. + /// + [JsonPropertyName("typeDefaults")] + public Dictionary TypeDefaults { get; set; } = new(); + + /// + /// Per-environment configurations keyed by normalized URL. + /// + [JsonPropertyName("environments")] + public List Environments { get; set; } = new(); +} +``` + +**Step 2: Commit** + +``` +git add src/PPDS.Auth/Profiles/EnvironmentConfigCollection.cs +git commit -m "feat(auth): add EnvironmentConfigCollection with typeDefaults and environments" +``` + +--- + +### Task 4: EnvironmentConfigStore (PPDS.Auth) + +**Files:** +- Create: `src/PPDS.Auth/Profiles/EnvironmentConfigStore.cs` +- Modify: `src/PPDS.Auth/Profiles/ProfilePaths.cs:24` — add `EnvironmentsFileName` constant + +**Step 1: Add file path constant to ProfilePaths** + +Add after line 24 (`ProfilesFileName`): + +```csharp +/// +/// Environment configuration file name. +/// +public const string EnvironmentsFileName = "environments.json"; + +/// +/// Gets the full path to the environment configuration file. +/// +public static string EnvironmentsFile => Path.Combine(DataDirectory, EnvironmentsFileName); +``` + +**Step 2: Create EnvironmentConfigStore** + +Follow the same pattern as `ProfileStore` (semaphore, caching, async/sync, IDisposable): + +```csharp +// src/PPDS.Auth/Profiles/EnvironmentConfigStore.cs +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace PPDS.Auth.Profiles; + +/// +/// Manages persistent storage of environment configurations. +/// +public sealed class EnvironmentConfigStore : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + private readonly string _filePath; + private readonly SemaphoreSlim _lock = new(1, 1); + private EnvironmentConfigCollection? _cached; + private bool _disposed; + + public EnvironmentConfigStore() : this(ProfilePaths.EnvironmentsFile) { } + + public EnvironmentConfigStore(string filePath) + { + _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + } + + public string FilePath => _filePath; + + public async Task LoadAsync(CancellationToken ct = default) + { + await _lock.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_cached != null) return _cached; + + if (!File.Exists(_filePath)) + { + _cached = new EnvironmentConfigCollection(); + return _cached; + } + + var json = await File.ReadAllTextAsync(_filePath, ct).ConfigureAwait(false); + _cached = JsonSerializer.Deserialize(json, JsonOptions) + ?? new EnvironmentConfigCollection(); + return _cached; + } + finally + { + _lock.Release(); + } + } + + public async Task SaveAsync(EnvironmentConfigCollection collection, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(collection); + + await _lock.WaitAsync(ct).ConfigureAwait(false); + try + { + ProfilePaths.EnsureDirectoryExists(); + collection.Version = 1; + var json = JsonSerializer.Serialize(collection, JsonOptions); + await File.WriteAllTextAsync(_filePath, json, ct).ConfigureAwait(false); + _cached = collection; + } + finally + { + _lock.Release(); + } + } + + /// + /// Gets the config for a specific environment URL, or null if not configured. + /// + public async Task GetConfigAsync(string url, CancellationToken ct = default) + { + var collection = await LoadAsync(ct).ConfigureAwait(false); + var normalized = EnvironmentConfig.NormalizeUrl(url); + return collection.Environments.FirstOrDefault( + e => EnvironmentConfig.NormalizeUrl(e.Url) == normalized); + } + + /// + /// Saves or updates config for a specific environment. Merges non-null fields. + /// + public async Task SaveConfigAsync( + string url, string? label = null, string? type = null, EnvironmentColor? color = null, + CancellationToken ct = default) + { + var collection = await LoadAsync(ct).ConfigureAwait(false); + var normalized = EnvironmentConfig.NormalizeUrl(url); + + var existing = collection.Environments.FirstOrDefault( + e => EnvironmentConfig.NormalizeUrl(e.Url) == normalized); + + if (existing != null) + { + if (label != null) existing.Label = label; + if (type != null) existing.Type = type; + if (color != null) existing.Color = color; + } + else + { + existing = new EnvironmentConfig + { + Url = normalized, + Label = label, + Type = type, + Color = color + }; + collection.Environments.Add(existing); + } + + await SaveAsync(collection, ct).ConfigureAwait(false); + return existing; + } + + /// + /// Removes config for a specific environment URL. + /// + public async Task RemoveConfigAsync(string url, CancellationToken ct = default) + { + var collection = await LoadAsync(ct).ConfigureAwait(false); + var normalized = EnvironmentConfig.NormalizeUrl(url); + var removed = collection.Environments.RemoveAll( + e => EnvironmentConfig.NormalizeUrl(e.Url) == normalized); + + if (removed > 0) + { + await SaveAsync(collection, ct).ConfigureAwait(false); + return true; + } + return false; + } + + public void ClearCache() + { + _lock.Wait(); + try { _cached = null; } + finally { _lock.Release(); } + } + + public void Dispose() + { + if (_disposed) return; + _lock.Dispose(); + _disposed = true; + } +} +``` + +**Step 3: Commit** + +``` +git add src/PPDS.Auth/Profiles/ProfilePaths.cs src/PPDS.Auth/Profiles/EnvironmentConfigStore.cs +git commit -m "feat(auth): add EnvironmentConfigStore for persistent environment configuration" +``` + +--- + +### Task 5: EnvironmentConfigStore Unit Tests + +**Files:** +- Create: `tests/PPDS.Cli.Tests/Services/Environment/EnvironmentConfigStoreTests.cs` + +**Step 1: Write the tests** + +```csharp +using PPDS.Auth.Profiles; +using Xunit; + +namespace PPDS.Cli.Tests.Services.Environment; + +[Trait("Category", "TuiUnit")] +public class EnvironmentConfigStoreTests : IDisposable +{ + private readonly string _tempDir; + private readonly EnvironmentConfigStore _store; + + public EnvironmentConfigStoreTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ppds-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _store = new EnvironmentConfigStore(Path.Combine(_tempDir, "environments.json")); + } + + [Fact] + public async Task LoadAsync_NoFile_ReturnsEmptyCollection() + { + var collection = await _store.LoadAsync(); + Assert.Empty(collection.Environments); + Assert.Empty(collection.TypeDefaults); + Assert.Equal(1, collection.Version); + } + + [Fact] + public async Task SaveConfigAsync_NewEnvironment_CreatesEntry() + { + var config = await _store.SaveConfigAsync( + "https://org.crm.dynamics.com", + label: "Contoso Prod", + type: "Production", + color: EnvironmentColor.Red); + + Assert.Equal("contoso prod", config.Label?.ToLowerInvariant()); + Assert.Equal("Production", config.Type); + Assert.Equal(EnvironmentColor.Red, config.Color); + } + + [Fact] + public async Task SaveConfigAsync_ExistingEnvironment_MergesFields() + { + await _store.SaveConfigAsync("https://org.crm.dynamics.com", label: "Original"); + var updated = await _store.SaveConfigAsync("https://org.crm.dynamics.com", type: "Sandbox"); + + Assert.Equal("Original", updated.Label); + Assert.Equal("Sandbox", updated.Type); + } + + [Fact] + public async Task SaveConfigAsync_NormalizesUrl() + { + await _store.SaveConfigAsync("https://ORG.CRM.DYNAMICS.COM", label: "Test"); + var config = await _store.GetConfigAsync("https://org.crm.dynamics.com/"); + + Assert.NotNull(config); + Assert.Equal("Test", config!.Label); + } + + [Fact] + public async Task GetConfigAsync_NotFound_ReturnsNull() + { + var config = await _store.GetConfigAsync("https://nonexistent.crm.dynamics.com"); + Assert.Null(config); + } + + [Fact] + public async Task RemoveConfigAsync_Existing_ReturnsTrue() + { + await _store.SaveConfigAsync("https://org.crm.dynamics.com", label: "Test"); + var removed = await _store.RemoveConfigAsync("https://org.crm.dynamics.com"); + + Assert.True(removed); + + var config = await _store.GetConfigAsync("https://org.crm.dynamics.com"); + Assert.Null(config); + } + + [Fact] + public async Task RemoveConfigAsync_NotFound_ReturnsFalse() + { + var removed = await _store.RemoveConfigAsync("https://nonexistent.crm.dynamics.com"); + Assert.False(removed); + } + + [Fact] + public async Task RoundTrip_PersistsToDisk() + { + await _store.SaveConfigAsync("https://org.crm.dynamics.com", + label: "Prod", type: "Production", color: EnvironmentColor.Red); + + // Create new store pointing to same file to verify persistence + using var store2 = new EnvironmentConfigStore(Path.Combine(_tempDir, "environments.json")); + var config = await store2.GetConfigAsync("https://org.crm.dynamics.com"); + + Assert.NotNull(config); + Assert.Equal("Prod", config!.Label); + Assert.Equal("Production", config.Type); + Assert.Equal(EnvironmentColor.Red, config.Color); + } + + [Fact] + public async Task TypeDefaults_RoundTrip() + { + var collection = await _store.LoadAsync(); + collection.TypeDefaults["UAT"] = EnvironmentColor.Brown; + collection.TypeDefaults["Gold"] = EnvironmentColor.BrightYellow; + await _store.SaveAsync(collection); + + _store.ClearCache(); + var reloaded = await _store.LoadAsync(); + + Assert.Equal(EnvironmentColor.Brown, reloaded.TypeDefaults["UAT"]); + Assert.Equal(EnvironmentColor.BrightYellow, reloaded.TypeDefaults["Gold"]); + } + + public void Dispose() + { + _store.Dispose(); + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } + catch { /* best effort */ } + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/PPDS.Cli.Tests --filter "FullyQualifiedName~EnvironmentConfigStoreTests" --no-build` +Expected: Build failure (store doesn't exist yet) or test failures + +**Step 3: Run tests after Task 4 implementation** + +Run: `dotnet test tests/PPDS.Cli.Tests --filter "FullyQualifiedName~EnvironmentConfigStoreTests"` +Expected: All 8 tests PASS + +**Step 4: Commit** + +``` +git add tests/PPDS.Cli.Tests/Services/Environment/EnvironmentConfigStoreTests.cs +git commit -m "test(auth): add EnvironmentConfigStore unit tests" +``` + +--- + +### Task 6: IEnvironmentConfigService Application Service + +**Files:** +- Create: `src/PPDS.Cli/Services/Environment/IEnvironmentConfigService.cs` + +**Step 1: Create the interface** + +```csharp +// src/PPDS.Cli/Services/Environment/IEnvironmentConfigService.cs +using PPDS.Auth.Profiles; + +namespace PPDS.Cli.Services.Environment; + +/// +/// Application service for managing environment configuration (labels, types, colors). +/// Shared across CLI, TUI, and RPC interfaces. +/// +public interface IEnvironmentConfigService +{ + /// + /// Gets the configuration for a specific environment, or null if not configured. + /// + Task GetConfigAsync(string url, CancellationToken ct = default); + + /// + /// Gets all configured environments. + /// + Task> GetAllConfigsAsync(CancellationToken ct = default); + + /// + /// Saves or merges configuration for a specific environment. + /// Only non-null parameters are updated (existing values preserved). + /// + Task SaveConfigAsync( + string url, + string? label = null, + string? type = null, + EnvironmentColor? color = null, + CancellationToken ct = default); + + /// + /// Removes configuration for a specific environment. + /// + Task RemoveConfigAsync(string url, CancellationToken ct = default); + + /// + /// Adds or updates a custom type definition with a default color. + /// + Task SaveTypeDefaultAsync(string typeName, EnvironmentColor color, CancellationToken ct = default); + + /// + /// Removes a custom type definition. + /// + Task RemoveTypeDefaultAsync(string typeName, CancellationToken ct = default); + + /// + /// Gets all type definitions (built-in + custom) with their default colors. + /// + Task> GetAllTypeDefaultsAsync(CancellationToken ct = default); + + /// + /// Resolves the effective color for an environment. + /// Priority: per-env color > type default color (custom then built-in) > Gray fallback. + /// + Task ResolveColorAsync(string url, CancellationToken ct = default); + + /// + /// Resolves the effective environment type string for an environment. + /// Priority: user config type > discovery API type > URL heuristics > null. + /// + Task ResolveTypeAsync(string url, string? discoveredType = null, CancellationToken ct = default); + + /// + /// Resolves the display label for an environment. + /// Priority: user config label > environment DisplayName from profile. + /// + Task ResolveLabelAsync(string url, CancellationToken ct = default); +} +``` + +**Step 2: Commit** + +``` +git add src/PPDS.Cli/Services/Environment/IEnvironmentConfigService.cs +git commit -m "feat(cli): add IEnvironmentConfigService interface" +``` + +--- + +### Task 7: EnvironmentConfigService Implementation + +**Files:** +- Create: `src/PPDS.Cli/Services/Environment/EnvironmentConfigService.cs` + +**Step 1: Create the implementation** + +```csharp +// src/PPDS.Cli/Services/Environment/EnvironmentConfigService.cs +using System.Text.RegularExpressions; +using PPDS.Auth.Profiles; + +namespace PPDS.Cli.Services.Environment; + +/// +/// Default implementation of . +/// +public sealed partial class EnvironmentConfigService : IEnvironmentConfigService +{ + /// + /// Built-in type defaults. Custom user types in environments.json override these. + /// + private static readonly Dictionary BuiltInTypeDefaults = new(StringComparer.OrdinalIgnoreCase) + { + ["Production"] = EnvironmentColor.Red, + ["Sandbox"] = EnvironmentColor.Brown, + ["Development"] = EnvironmentColor.Green, + ["Test"] = EnvironmentColor.Yellow, + ["Trial"] = EnvironmentColor.Cyan, + }; + + private readonly EnvironmentConfigStore _store; + + public EnvironmentConfigService(EnvironmentConfigStore store) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + public async Task GetConfigAsync(string url, CancellationToken ct = default) + => await _store.GetConfigAsync(url, ct).ConfigureAwait(false); + + public async Task> GetAllConfigsAsync(CancellationToken ct = default) + { + var collection = await _store.LoadAsync(ct).ConfigureAwait(false); + return collection.Environments.AsReadOnly(); + } + + public async Task SaveConfigAsync( + string url, string? label = null, string? type = null, EnvironmentColor? color = null, + CancellationToken ct = default) + => await _store.SaveConfigAsync(url, label, type, color, ct).ConfigureAwait(false); + + public async Task RemoveConfigAsync(string url, CancellationToken ct = default) + => await _store.RemoveConfigAsync(url, ct).ConfigureAwait(false); + + public async Task SaveTypeDefaultAsync(string typeName, EnvironmentColor color, CancellationToken ct = default) + { + var collection = await _store.LoadAsync(ct).ConfigureAwait(false); + collection.TypeDefaults[typeName] = color; + await _store.SaveAsync(collection, ct).ConfigureAwait(false); + } + + public async Task RemoveTypeDefaultAsync(string typeName, CancellationToken ct = default) + { + var collection = await _store.LoadAsync(ct).ConfigureAwait(false); + if (collection.TypeDefaults.Remove(typeName)) + { + await _store.SaveAsync(collection, ct).ConfigureAwait(false); + return true; + } + return false; + } + + public async Task> GetAllTypeDefaultsAsync(CancellationToken ct = default) + { + var collection = await _store.LoadAsync(ct).ConfigureAwait(false); + + // Merge built-in with custom (custom wins on conflict) + var merged = new Dictionary(BuiltInTypeDefaults, StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in collection.TypeDefaults) + { + merged[key] = value; + } + return merged; + } + + public async Task ResolveColorAsync(string url, CancellationToken ct = default) + { + var config = await _store.GetConfigAsync(url, ct).ConfigureAwait(false); + + // Priority 1: per-environment explicit color + if (config?.Color != null) + return config.Color.Value; + + // Priority 2: type-based color (resolve type first) + var type = config?.Type ?? DetectTypeFromUrl(url); + if (type != null) + { + var allDefaults = await GetAllTypeDefaultsAsync(ct).ConfigureAwait(false); + if (allDefaults.TryGetValue(type, out var typeColor)) + return typeColor; + } + + // Priority 3: fallback + return EnvironmentColor.Gray; + } + + public async Task ResolveTypeAsync(string url, string? discoveredType = null, CancellationToken ct = default) + { + // Priority 1: user config type + var config = await _store.GetConfigAsync(url, ct).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(config?.Type)) + return config!.Type; + + // Priority 2: discovery API type + if (!string.IsNullOrWhiteSpace(discoveredType)) + return discoveredType; + + // Priority 3: URL heuristics + return DetectTypeFromUrl(url); + } + + public async Task ResolveLabelAsync(string url, CancellationToken ct = default) + { + var config = await _store.GetConfigAsync(url, ct).ConfigureAwait(false); + return config?.Label; + } + + #region URL Heuristics (fallback only) + + [GeneratedRegex(@"\.crm\d+\.dynamics\.com", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex SandboxRegex(); + + private static readonly string[] DevKeywords = ["dev", "develop", "development", "test", "qa", "uat"]; + private static readonly string[] TrialKeywords = ["trial", "demo", "preview"]; + + /// + /// Last-resort URL-based detection. Only used when no config or discovery type exists. + /// + internal static string? DetectTypeFromUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + + var lower = url.ToLowerInvariant(); + + if (DevKeywords.Any(k => lower.Contains(k, StringComparison.OrdinalIgnoreCase))) + return "Development"; + if (TrialKeywords.Any(k => lower.Contains(k, StringComparison.OrdinalIgnoreCase))) + return "Trial"; + if (SandboxRegex().IsMatch(lower)) + return "Sandbox"; + if (lower.Contains(".crm.dynamics.com")) + return "Production"; + + return null; + } + + #endregion +} +``` + +**Step 2: Commit** + +``` +git add src/PPDS.Cli/Services/Environment/EnvironmentConfigService.cs +git commit -m "feat(cli): implement EnvironmentConfigService with type/color resolution" +``` + +--- + +### Task 8: EnvironmentConfigService Unit Tests + +**Files:** +- Create: `tests/PPDS.Cli.Tests/Services/Environment/EnvironmentConfigServiceTests.cs` + +**Step 1: Write the tests** + +```csharp +using PPDS.Auth.Profiles; +using PPDS.Cli.Services.Environment; +using Xunit; + +namespace PPDS.Cli.Tests.Services.Environment; + +[Trait("Category", "TuiUnit")] +public class EnvironmentConfigServiceTests : IDisposable +{ + private readonly string _tempDir; + private readonly EnvironmentConfigStore _store; + private readonly EnvironmentConfigService _service; + + public EnvironmentConfigServiceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ppds-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _store = new EnvironmentConfigStore(Path.Combine(_tempDir, "environments.json")); + _service = new EnvironmentConfigService(_store); + } + + [Fact] + public async Task ResolveColorAsync_ExplicitColor_WinsOverType() + { + await _service.SaveConfigAsync("https://org.crm.dynamics.com", + type: "Production", color: EnvironmentColor.Blue); + + var color = await _service.ResolveColorAsync("https://org.crm.dynamics.com"); + Assert.Equal(EnvironmentColor.Blue, color); + } + + [Fact] + public async Task ResolveColorAsync_TypeDefault_UsedWhenNoExplicitColor() + { + await _service.SaveConfigAsync("https://org.crm.dynamics.com", type: "Production"); + + var color = await _service.ResolveColorAsync("https://org.crm.dynamics.com"); + Assert.Equal(EnvironmentColor.Red, color); + } + + [Fact] + public async Task ResolveColorAsync_CustomType_UsesTypeDefaults() + { + await _service.SaveTypeDefaultAsync("Gold", EnvironmentColor.BrightYellow); + await _service.SaveConfigAsync("https://org.crm.dynamics.com", type: "Gold"); + + var color = await _service.ResolveColorAsync("https://org.crm.dynamics.com"); + Assert.Equal(EnvironmentColor.BrightYellow, color); + } + + [Fact] + public async Task ResolveColorAsync_NoConfig_FallsBackToUrlHeuristics() + { + // .crm.dynamics.com (no number) => Production => Red + var color = await _service.ResolveColorAsync("https://org.crm.dynamics.com"); + Assert.Equal(EnvironmentColor.Red, color); + } + + [Fact] + public async Task ResolveColorAsync_UnknownUrl_ReturnsGray() + { + var color = await _service.ResolveColorAsync("https://some-random-url.example.com"); + Assert.Equal(EnvironmentColor.Gray, color); + } + + [Fact] + public async Task ResolveTypeAsync_UserConfig_WinsOverDiscovery() + { + await _service.SaveConfigAsync("https://org.crm.dynamics.com", type: "UAT"); + + var type = await _service.ResolveTypeAsync("https://org.crm.dynamics.com", discoveredType: "Sandbox"); + Assert.Equal("UAT", type); + } + + [Fact] + public async Task ResolveTypeAsync_Discovery_WinsOverUrlHeuristics() + { + var type = await _service.ResolveTypeAsync("https://org.crm.dynamics.com", discoveredType: "Sandbox"); + Assert.Equal("Sandbox", type); + } + + [Fact] + public async Task ResolveTypeAsync_NoConfigNoDiscovery_FallsBackToUrl() + { + var type = await _service.ResolveTypeAsync("https://org-dev.crm.dynamics.com"); + Assert.Equal("Development", type); + } + + [Fact] + public async Task GetAllTypeDefaultsAsync_MergesBuiltInAndCustom() + { + await _service.SaveTypeDefaultAsync("Gold", EnvironmentColor.BrightYellow); + + var defaults = await _service.GetAllTypeDefaultsAsync(); + + Assert.True(defaults.ContainsKey("Production"), "Should have built-in Production"); + Assert.True(defaults.ContainsKey("Gold"), "Should have custom Gold"); + Assert.Equal(EnvironmentColor.Red, defaults["Production"]); + Assert.Equal(EnvironmentColor.BrightYellow, defaults["Gold"]); + } + + [Fact] + public async Task GetAllTypeDefaultsAsync_CustomOverridesBuiltIn() + { + // Override built-in "Production" color + await _service.SaveTypeDefaultAsync("Production", EnvironmentColor.BrightRed); + + var defaults = await _service.GetAllTypeDefaultsAsync(); + Assert.Equal(EnvironmentColor.BrightRed, defaults["Production"]); + } + + [Fact] + public async Task ResolveLabelAsync_ReturnsConfiguredLabel() + { + await _service.SaveConfigAsync("https://org.crm.dynamics.com", label: "Contoso Prod"); + + var label = await _service.ResolveLabelAsync("https://org.crm.dynamics.com"); + Assert.Equal("Contoso Prod", label); + } + + [Fact] + public async Task ResolveLabelAsync_NoConfig_ReturnsNull() + { + var label = await _service.ResolveLabelAsync("https://org.crm.dynamics.com"); + Assert.Null(label); + } + + [Fact] + public void DetectTypeFromUrl_ProductionUrl() + { + Assert.Equal("Production", EnvironmentConfigService.DetectTypeFromUrl("https://org.crm.dynamics.com")); + } + + [Fact] + public void DetectTypeFromUrl_SandboxUrl() + { + Assert.Equal("Sandbox", EnvironmentConfigService.DetectTypeFromUrl("https://org.crm9.dynamics.com")); + } + + [Fact] + public void DetectTypeFromUrl_DevKeyword() + { + Assert.Equal("Development", EnvironmentConfigService.DetectTypeFromUrl("https://org-dev.crm.dynamics.com")); + } + + [Fact] + public void DetectTypeFromUrl_UnknownUrl() + { + Assert.Null(EnvironmentConfigService.DetectTypeFromUrl("https://some-random.example.com")); + } + + public void Dispose() + { + _store.Dispose(); + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } + catch { /* best effort */ } + } +} +``` + +**Step 2: Run tests** + +Run: `dotnet test tests/PPDS.Cli.Tests --filter "FullyQualifiedName~EnvironmentConfigServiceTests"` +Expected: All 14 tests PASS + +**Step 3: Commit** + +``` +git add tests/PPDS.Cli.Tests/Services/Environment/EnvironmentConfigServiceTests.cs +git commit -m "test(cli): add EnvironmentConfigService unit tests for resolution priority" +``` + +--- + +### Task 9: Wire TuiThemeService to Use EnvironmentConfigService + +**Files:** +- Modify: `src/PPDS.Cli/Tui/Infrastructure/ITuiThemeService.cs` +- Modify: `src/PPDS.Cli/Tui/Infrastructure/TuiThemeService.cs` +- Modify: `src/PPDS.Cli/Tui/Infrastructure/TuiColorPalette.cs:303-310` — add `EnvironmentColor` mapping + +**Step 1: Add EnvironmentColor-to-ColorScheme mapping in TuiColorPalette** + +Add a new method after `GetStatusBarScheme(EnvironmentType)`: + +```csharp +/// +/// Gets the status bar color scheme for a user-configured environment color. +/// +public static ColorScheme GetStatusBarScheme(EnvironmentColor envColor) => envColor switch +{ + EnvironmentColor.Red => StatusBar_Production, + EnvironmentColor.Brown => StatusBar_Sandbox, + EnvironmentColor.Green => StatusBar_Development, + EnvironmentColor.Cyan => StatusBar_Trial, + EnvironmentColor.Yellow => new ColorScheme + { + Normal = MakeAttr(Color.Black, Color.BrightYellow), + Focus = MakeAttr(Color.Black, Color.BrightYellow), + HotNormal = MakeAttr(Color.Red, Color.BrightYellow), + HotFocus = MakeAttr(Color.Red, Color.BrightYellow), + Disabled = MakeAttr(Color.DarkGray, Color.BrightYellow) + }, + EnvironmentColor.Blue => new ColorScheme + { + Normal = MakeAttr(Color.Black, Color.Blue), + Focus = MakeAttr(Color.Black, Color.BrightBlue), + HotNormal = MakeAttr(Color.Black, Color.Blue), + HotFocus = MakeAttr(Color.Black, Color.BrightBlue), + Disabled = MakeAttr(Color.Black, Color.Blue) + }, + EnvironmentColor.Gray => StatusBar_Default, + EnvironmentColor.BrightRed => new ColorScheme + { + Normal = MakeAttr(Color.White, Color.BrightRed), + Focus = MakeAttr(Color.White, Color.BrightRed), + HotNormal = MakeAttr(Color.BrightYellow, Color.BrightRed), + HotFocus = MakeAttr(Color.BrightYellow, Color.BrightRed), + Disabled = MakeAttr(Color.Gray, Color.BrightRed) + }, + EnvironmentColor.BrightGreen => new ColorScheme + { + Normal = MakeAttr(Color.Black, Color.BrightGreen), + Focus = MakeAttr(Color.Black, Color.BrightGreen), + HotNormal = MakeAttr(Color.Black, Color.BrightGreen), + HotFocus = MakeAttr(Color.Black, Color.BrightGreen), + Disabled = MakeAttr(Color.DarkGray, Color.BrightGreen) + }, + EnvironmentColor.BrightYellow => new ColorScheme + { + Normal = MakeAttr(Color.Black, Color.BrightYellow), + Focus = MakeAttr(Color.Black, Color.BrightYellow), + HotNormal = MakeAttr(Color.Red, Color.BrightYellow), + HotFocus = MakeAttr(Color.Red, Color.BrightYellow), + Disabled = MakeAttr(Color.DarkGray, Color.BrightYellow) + }, + EnvironmentColor.BrightCyan => new ColorScheme + { + Normal = MakeAttr(Color.Black, Color.BrightCyan), + Focus = MakeAttr(Color.Black, Color.BrightCyan), + HotNormal = MakeAttr(Color.Black, Color.BrightCyan), + HotFocus = MakeAttr(Color.Black, Color.BrightCyan), + Disabled = MakeAttr(Color.Black, Color.BrightCyan) + }, + EnvironmentColor.BrightBlue => new ColorScheme + { + Normal = MakeAttr(Color.Black, Color.BrightBlue), + Focus = MakeAttr(Color.Black, Color.BrightBlue), + HotNormal = MakeAttr(Color.Black, Color.BrightBlue), + HotFocus = MakeAttr(Color.Black, Color.BrightBlue), + Disabled = MakeAttr(Color.Black, Color.BrightBlue) + }, + EnvironmentColor.White => new ColorScheme + { + Normal = MakeAttr(Color.Black, Color.White), + Focus = MakeAttr(Color.Black, Color.White), + HotNormal = MakeAttr(Color.Blue, Color.White), + HotFocus = MakeAttr(Color.Blue, Color.White), + Disabled = MakeAttr(Color.DarkGray, Color.White) + }, + _ => StatusBar_Default +}; + +/// +/// Maps EnvironmentColor to Terminal.Gui foreground Color (for tab tinting). +/// +public static Color GetForegroundColor(EnvironmentColor envColor) => envColor switch +{ + EnvironmentColor.Red => Color.Red, + EnvironmentColor.Green => Color.Green, + EnvironmentColor.Yellow => Color.BrightYellow, + EnvironmentColor.Cyan => Color.Cyan, + EnvironmentColor.Blue => Color.Blue, + EnvironmentColor.Gray => Color.Gray, + EnvironmentColor.Brown => Color.Brown, + EnvironmentColor.BrightRed => Color.BrightRed, + EnvironmentColor.BrightGreen => Color.BrightGreen, + EnvironmentColor.BrightYellow => Color.BrightYellow, + EnvironmentColor.BrightCyan => Color.BrightCyan, + EnvironmentColor.BrightBlue => Color.BrightBlue, + EnvironmentColor.White => Color.White, + _ => Color.Gray +}; +``` + +**Step 2: Update ITuiThemeService — add config-aware methods** + +Add to the interface: + +```csharp +/// +/// Gets the status bar color scheme using the environment config service. +/// Falls back to URL-based detection if no config exists. +/// +ColorScheme GetStatusBarSchemeForUrl(string? environmentUrl); + +/// +/// Gets the environment label using the config service. +/// Falls back to URL-based detection if no config exists. +/// +string GetEnvironmentLabelForUrl(string? environmentUrl); + +/// +/// Gets the resolved environment color for tab tinting. +/// +EnvironmentColor GetResolvedColor(string? environmentUrl); +``` + +**Step 3: Update TuiThemeService — inject IEnvironmentConfigService** + +The `TuiThemeService` constructor should accept an optional `IEnvironmentConfigService`. When present, the new methods use it. The old `DetectEnvironmentType` method stays for backward compatibility but is no longer the primary path. + +```csharp +public sealed partial class TuiThemeService : ITuiThemeService +{ + private readonly IEnvironmentConfigService? _configService; + + public TuiThemeService(IEnvironmentConfigService? configService = null) + { + _configService = configService; + } + + // ... existing methods stay ... + + public ColorScheme GetStatusBarSchemeForUrl(string? environmentUrl) + { + if (string.IsNullOrWhiteSpace(environmentUrl)) + return TuiColorPalette.StatusBar_Default; + + if (_configService != null) + { + // Synchronous wrapper — theme service is called from UI thread + var color = _configService.ResolveColorAsync(environmentUrl).GetAwaiter().GetResult(); + return TuiColorPalette.GetStatusBarScheme(color); + } + + // Fallback to old detection + var envType = DetectEnvironmentType(environmentUrl); + return TuiColorPalette.GetStatusBarScheme(envType); + } + + public string GetEnvironmentLabelForUrl(string? environmentUrl) + { + if (string.IsNullOrWhiteSpace(environmentUrl)) + return ""; + + if (_configService != null) + { + var type = _configService.ResolveTypeAsync(environmentUrl).GetAwaiter().GetResult(); + return type?.ToUpperInvariant() switch + { + "PRODUCTION" => "PROD", + "DEVELOPMENT" => "DEV", + var t when t != null && t.Length <= 8 => t, + var t when t != null => t[..8], + _ => "" + }; + } + + return GetEnvironmentLabel(DetectEnvironmentType(environmentUrl)); + } + + public EnvironmentColor GetResolvedColor(string? environmentUrl) + { + if (string.IsNullOrWhiteSpace(environmentUrl)) + return EnvironmentColor.Gray; + + if (_configService != null) + return _configService.ResolveColorAsync(environmentUrl).GetAwaiter().GetResult(); + + // Fallback: map old EnvironmentType to EnvironmentColor + var envType = DetectEnvironmentType(environmentUrl); + return envType switch + { + EnvironmentType.Production => EnvironmentColor.Red, + EnvironmentType.Sandbox => EnvironmentColor.Brown, + EnvironmentType.Development => EnvironmentColor.Green, + EnvironmentType.Trial => EnvironmentColor.Cyan, + _ => EnvironmentColor.Gray + }; + } +} +``` + +**Step 4: Commit** + +``` +git add src/PPDS.Cli/Tui/Infrastructure/ITuiThemeService.cs src/PPDS.Cli/Tui/Infrastructure/TuiThemeService.cs src/PPDS.Cli/Tui/Infrastructure/TuiColorPalette.cs +git commit -m "feat(tui): wire TuiThemeService to EnvironmentConfigService for user-configurable colors" +``` + +--- + +### Task 10: Update TUI Consumers to Use Config-Aware Methods + +**Files:** +- Modify: `src/PPDS.Cli/Tui/Views/TuiStatusBar.cs:111,188-189` — use `GetStatusBarSchemeForUrl` and `GetEnvironmentLabelForUrl` +- Modify: `src/PPDS.Cli/Tui/Infrastructure/TabManager.cs:41` — use `GetResolvedColor` instead of `DetectEnvironmentType` +- Modify: `src/PPDS.Cli/Tui/Views/TabBar.cs` — use `GetForegroundColor(EnvironmentColor)` for tab tinting + +**Step 1: Update TuiStatusBar.Redraw and UpdateDisplay** + +In `Redraw()` (line 111), change: +```csharp +// Before: +var envType = _themeService.DetectEnvironmentType(_session.CurrentEnvironmentUrl); +var colorScheme = _themeService.GetStatusBarScheme(envType); + +// After: +var colorScheme = _themeService.GetStatusBarSchemeForUrl(_session.CurrentEnvironmentUrl); +``` + +In `UpdateDisplay()` (lines 188-189), change: +```csharp +// Before: +var envType = _themeService.DetectEnvironmentType(_session.CurrentEnvironmentUrl); +var envLabel = _themeService.GetEnvironmentLabel(envType); + +// After: +var envLabel = _themeService.GetEnvironmentLabelForUrl(_session.CurrentEnvironmentUrl); +``` + +In `CaptureState()` (line 219), change: +```csharp +// Before: +var envType = _themeService.DetectEnvironmentType(_session.CurrentEnvironmentUrl); + +// After: keep for state capture, but also capture resolved color +var envType = _themeService.DetectEnvironmentType(_session.CurrentEnvironmentUrl); +``` + +**Step 2: Update TabManager.AddTab** + +In `AddTab()` (line 41), the `TabInfo` record needs to store `EnvironmentColor` instead of (or in addition to) `EnvironmentType`: + +Update `TabInfo` record: +```csharp +internal sealed record TabInfo( + ITuiScreen Screen, + string? EnvironmentUrl, + string? EnvironmentDisplayName, + EnvironmentType EnvironmentType, + EnvironmentColor EnvironmentColor); +``` + +Update `AddTab()`: +```csharp +public void AddTab(ITuiScreen screen, string? environmentUrl, string? environmentDisplayName = null) +{ + var envType = _themeService.DetectEnvironmentType(environmentUrl); + var envColor = _themeService.GetResolvedColor(environmentUrl); + var tab = new TabInfo(screen, environmentUrl, environmentDisplayName, envType, envColor); + _tabs.Add(tab); + _activeIndex = _tabs.Count - 1; + + TabsChanged?.Invoke(); + ActiveTabChanged?.Invoke(); +} +``` + +**Step 3: Update TabBar to use EnvironmentColor for inactive tab tinting** + +Find where `GetTabScheme` is called with `EnvironmentType` and update to use `EnvironmentColor`: + +```csharp +// In TuiColorPalette, update GetTabScheme: +public static ColorScheme GetTabScheme(EnvironmentColor envColor, bool isActive) +{ + if (isActive) return TabActive; + + var fg = GetForegroundColor(envColor); + return new ColorScheme + { + Normal = MakeAttr(fg, Color.Black), + Focus = MakeAttr(Color.White, Color.Black), + HotNormal = MakeAttr(fg, Color.Black), + HotFocus = MakeAttr(Color.White, Color.Black), + Disabled = MakeAttr(Color.DarkGray, Color.Black) + }; +} +``` + +Keep the old `GetTabScheme(EnvironmentType, bool)` overload for backward compatibility until all callers are migrated. + +**Step 4: Commit** + +``` +git add src/PPDS.Cli/Tui/Views/TuiStatusBar.cs src/PPDS.Cli/Tui/Infrastructure/TabManager.cs src/PPDS.Cli/Tui/Views/TabBar.cs src/PPDS.Cli/Tui/Infrastructure/TuiColorPalette.cs +git commit -m "feat(tui): update status bar, tabs, and tab bar to use configurable environment colors" +``` + +--- + +### Task 11: CLI `ppds env config` Command + +**Files:** +- Modify: `src/PPDS.Cli/Commands/Env/EnvCommandGroup.cs` — add `config` subcommand + +**Step 1: Add config subcommand to Create() and CreateOrgAlias()** + +In both `Create()` and `CreateOrgAlias()`, add: +```csharp +command.Subcommands.Add(CreateConfigCommand()); +command.Subcommands.Add(CreateTypeCommand()); +``` + +**Step 2: Implement CreateConfigCommand** + +```csharp +private static Command CreateConfigCommand() +{ + var urlArgument = new Argument("url") + { + Description = "Environment URL to configure" + }; + urlArgument.Arity = ArgumentArity.ZeroOrOne; + + var labelOption = new Option("--label", "-l") + { + Description = "Short display label for status bar and tabs" + }; + + var typeOption = new Option("--type", "-t") + { + Description = "Environment type (e.g., Production, Sandbox, Development, Test, Trial, or custom)" + }; + + var colorOption = new Option("--color", "-c") + { + Description = "Status bar color. Valid values: Red, Green, Yellow, Cyan, Blue, Gray, Brown, BrightRed, BrightGreen, BrightYellow, BrightCyan, BrightBlue, White" + }; + + var showOption = new Option("--show", "-s") + { + Description = "Show current configuration for the environment" + }; + + var listOption = new Option("--list") + { + Description = "List all configured environments" + }; + + var removeOption = new Option("--remove") + { + Description = "Remove configuration for the environment" + }; + + var command = new Command("config", "Configure environment display settings (label, type, color)") + { + urlArgument, labelOption, typeOption, colorOption, showOption, listOption, removeOption + }; + + command.SetAction(async (parseResult, cancellationToken) => + { + var url = parseResult.GetValue(urlArgument); + var label = parseResult.GetValue(labelOption); + var type = parseResult.GetValue(typeOption); + var color = parseResult.GetValue(colorOption); + var show = parseResult.GetValue(showOption); + var list = parseResult.GetValue(listOption); + var remove = parseResult.GetValue(removeOption); + + using var store = new EnvironmentConfigStore(); + var service = new EnvironmentConfigService(store); + + if (list) + return await ExecuteConfigListAsync(service, cancellationToken); + + if (string.IsNullOrWhiteSpace(url)) + { + Console.Error.WriteLine("Error: Environment URL is required. Use --list to see all configs."); + return ExitCodes.Failure; + } + + if (show) + return await ExecuteConfigShowAsync(service, url, cancellationToken); + + if (remove) + return await ExecuteConfigRemoveAsync(service, url, cancellationToken); + + if (label == null && type == null && color == null) + { + // No options provided — show current config + return await ExecuteConfigShowAsync(service, url, cancellationToken); + } + + return await ExecuteConfigSetAsync(service, url, label, type, color, cancellationToken); + }); + + return command; +} +``` + +**Step 3: Implement handler methods** + +```csharp +private static async Task ExecuteConfigSetAsync( + EnvironmentConfigService service, string url, + string? label, string? type, EnvironmentColor? color, + CancellationToken ct) +{ + var config = await service.SaveConfigAsync(url, label, type, color, ct); + + Console.ForegroundColor = ConsoleColor.Green; + Console.Error.WriteLine("Environment configuration saved."); + Console.ResetColor(); + + WriteConfigDetails(config); + return ExitCodes.Success; +} + +private static async Task ExecuteConfigShowAsync( + EnvironmentConfigService service, string url, CancellationToken ct) +{ + var config = await service.GetConfigAsync(url, ct); + if (config == null) + { + Console.Error.WriteLine($"No configuration found for: {url}"); + Console.Error.WriteLine("Use 'ppds env config --label