diff --git a/.gitignore b/.gitignore index 25a634d..e5d4b43 100644 --- a/.gitignore +++ b/.gitignore @@ -417,4 +417,5 @@ FodyWeavers.xsd *.msm *.msp -temp \ No newline at end of file +temp +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0c59fc..4a330df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,17 +8,32 @@ If you want to add a command, change a command's shape, or introduce a new surfa ## Top-level taxonomy -The CLI has four top-level groups, in this order, by design: +The CLI has five top-level groups, in this order, by design: ``` txc -├── profile # identity the CLI acts as (deferred) +├── config # identity the CLI acts as (profiles, connections, credentials, settings) ├── workspace # the local code repository (scaffold, build, validate, language-server, metamodel) ├── environment # the live target runtime footprint the workspace deploys to -└── data # data migration, transformation, imports +├── data # data migration, transformation, imports +└── docs # knowledge base for TALXIS CLI ``` -These four groups are deliberately small. Adding a fifth top-level group requires a strong justification — if a new piece of functionality fits under an existing noun, put it there. +These five groups are deliberately small. Adding a sixth top-level group requires a strong justification — if a new piece of functionality fits under an existing noun, put it there. + +### `config` sub-nouns + +The `config` group has four sub-nouns, each owning one aspect of the resolution pipeline: + +``` +txc config +├── auth # credentials (OAuth tokens, service principals) stored in the OS vault +├── connection # service endpoint metadata (Dataverse environments, etc.) +├── profile # named binding of one auth × one connection — the "context" users switch between +└── setting # tool-wide preferences (log.level, log.format, telemetry.enabled) +``` + +The separation is deliberate. Connections are *where*, credentials are *who*, profiles are the *context* mapping them, and settings are tool-level knobs unrelated to any identity. Do not collapse them or teach one sub-noun to write into another's store. --- @@ -27,7 +42,7 @@ These four groups are deliberately small. Adding a fifth top-level group require Command paths describe **what the user is doing**, not **what platform implements it**. - **No platform names in user-facing paths.** The word `dataverse` does not appear in any command path, and neither will any future platform name (`azure`, `entra`, `graph`, etc.). Users should not need to know or care which runtime their workspace artifacts land on. -- **Platforms live internally.** Platform-specific code lives under `Platforms//` inside the owning project (e.g. `TALXIS.CLI.Environment/Platforms/Dataverse/`). Do **not** create a `CliCommand` — platforms are not command groups. +- **Platforms live in dedicated projects.** Platform-specific implementations live in `TALXIS.CLI.Platform.` projects (e.g. `TALXIS.CLI.Platform.Dataverse`). Feature projects depend on abstractions in `TALXIS.CLI.Core`, never directly on a `Platform.*` project. Provider registration happens in the host (`TALXIS.CLI` / `TALXIS.CLI.MCP`). Do **not** create a `CliCommand` — platforms are not command groups. - **Abstractions are extracted, not speculated.** When a second platform is actually implemented, extract an interface from the shape that already exists. Do not speculate an `IEnvironmentPlatform` (or similar) before there is a second concrete implementation to validate it. We avoid even the term "backend" for this abstraction: a Dataverse environment carries metadata for frontend (forms, apps), middle tier (business logic, plugins, workflows), and integrations. Calling it a backend understates what ships there. @@ -114,16 +129,18 @@ Short-flag aliases (`-v`, `-y`, etc.) are out of scope until there is a concrete Long, explicit names are the canonical form, but **group** commands (the ones that hold children, not leaves) carry an `Alias` so day-to-day typing and README snippets stay short. Current aliases: -| Canonical | Alias | -| ------------------ | ------- | -| `environment` | `env` | -| `env deployment` | `deploy`| -| `env package` | `pkg` | -| `env solution` | `sln` | -| `data package` | `pkg` | -| `workspace` | `ws` | -| `workspace component` | `c` | -| `workspace project` | `p` | +| Canonical | Alias | +| --------------------- | ------- | +| `config` | `c` | +| `config profile` | `p` | +| `environment` | `env` | +| `env deployment` | `deploy`| +| `env package` | `pkg` | +| `env solution` | `sln` | +| `data package` | `pkg` | +| `workspace` | `ws` | +| `workspace component` | `c` | +| `workspace project` | `p` | Rules: @@ -131,6 +148,7 @@ Rules: - **Canonical names drive everything machine-readable.** MCP tool names, help anchors, the SDK surface — all built from `Name`. Aliases are a typing shortcut for humans; they never leak into tool IDs. - **One alias per command.** If you find yourself reaching for a second alias, rename the canonical instead. - **Prefer README and docs to use the alias** in example snippets — that's what the alias is for. Use the canonical name in reference tables, help output, and anywhere a reader needs to scan the full taxonomy. +- **Short-alias exception for `--profile`.** Because `config profile select` is the single most frequently typed command on a dev laptop, the `--profile` flag on every auth-requiring leaf command exposes the short form `-p`. This is the only flag-level short alias in the CLI. --- @@ -165,10 +183,10 @@ We use this mechanism to pin the design of reserved-but-not-yet-implemented comm Current reserved skeletons: -- `TALXIS.CLI.Environment.Deployment.DeploymentPatchCliCommand` → future `environment deployment patch`. -- `TALXIS.CLI.Workspace.WorkspaceValidateCliCommand` → future `workspace validate`. -- `TALXIS.CLI.Workspace.WorkspaceLanguageServerCliCommand` → future `workspace language-server`. -- `TALXIS.CLI.Workspace.Metamodel.MetamodelCliCommand` + `MetamodelDescribeCliCommand` + `MetamodelListCliCommand` → future `workspace metamodel {describe,list}`. +- `TALXIS.CLI.Features.Environment.Deployment.DeploymentPatchCliCommand` → future `environment deployment patch`. +- `TALXIS.CLI.Features.Workspace.WorkspaceValidateCliCommand` → future `workspace validate`. +- `TALXIS.CLI.Features.Workspace.WorkspaceLanguageServerCliCommand` → future `workspace language-server`. +- `TALXIS.CLI.Features.Workspace.Metamodel.MetamodelCliCommand` + `MetamodelDescribeCliCommand` + `MetamodelListCliCommand` → future `workspace metamodel {describe,list}`. Each skeleton throws `NotImplementedException` and carries a file-top comment explaining that it is intentionally unreachable and how to activate it. @@ -198,15 +216,42 @@ Never call the metamodel "the model" — it collides with the user's data model When a second runtime platform actually needs to be supported: -1. Add `Platforms//` inside the owning project (e.g. `TALXIS.CLI.Environment/Platforms/Azure/`). -2. Put all platform-specific services in that folder, in a `TALXIS.CLI.Environment.Platforms.` namespace. -3. Keep the command classes platform-agnostic: a single `Package.PackageImportCliCommand` dispatches to the right platform internally. -4. At this point (and not before), extract a shared abstraction — e.g. `IEnvironmentPlatform` — from the two real shapes. Do not write the interface before the second implementation exists. +1. Add a new `TALXIS.CLI.Platform.` project (e.g. `TALXIS.CLI.Platform.Azure`) alongside the existing `Platform.*` projects. +2. Put all platform-specific services in that project, under a `TALXIS.CLI.Platform.` namespace. +3. Register the provider's services in an `AddTxcProvider` extension, and wire it up from the host composition roots (`TALXIS.CLI/Program.cs` and `TALXIS.CLI.MCP/Program.cs`). +4. Keep the command classes platform-agnostic: a single `Package.PackageImportCliCommand` resolves the right implementation via the DI container, it does not reference a `Platform.*` project directly. +5. At this point (and not before), extract a shared abstraction in `TALXIS.CLI.Core` — e.g. `IEnvironmentPlatform` — from the two real shapes. Do not write the interface before the second implementation exists. Do not add a `CliCommand`. Platforms are implementation details; they do not surface as command groups. --- +## Project layout + +Projects are grouped by architectural role. Names must reflect this role: + +- **Hosts** — thin entrypoints that compose DI and register commands. Nothing else. + - `TALXIS.CLI` — the `txc` CLI host. + - `TALXIS.CLI.MCP` — the MCP server host. +- **Features** — user-facing command surfaces and orchestration, organised by domain. + - `TALXIS.CLI.Features.Config`, `TALXIS.CLI.Features.Data`, `TALXIS.CLI.Features.Environment`, `TALXIS.CLI.Features.Workspace`, `TALXIS.CLI.Features.Docs`. +- **Core** — contracts, models, configuration, vault, resolution, shared utilities. + - `TALXIS.CLI.Core`. +- **Platform** — external-system adapters and SDK integration. + - `TALXIS.CLI.Platform.Dataverse`, `TALXIS.CLI.Platform.Xrm`, `TALXIS.CLI.Platform.XrmShim`. +- **Cross-cutting** — infrastructure. + - `TALXIS.CLI.Logging`. + +**Layering rules:** + +- Features depend on `Core` and `Logging` only. Features do **not** reference `Platform.*` projects. +- Platform depends on `Core` (and external SDKs). Platform does **not** reference `Features.*`. +- Hosts reference everything they need for composition: `Features.*`, `Platform.*`, `Core`, `Logging`. +- No feature references another feature. Shared logic goes into `Core`. +- Provider selection happens at the host composition root, never inside a command handler. + +--- + ## Questions, disagreements, changes If you think the philosophy above is wrong for a specific case, open an issue or a draft PR that explains the case and proposes a targeted amendment to this document. The rule is: change the document first, then write the code that follows it. diff --git a/README.md b/README.md index 284a328..e519d95 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ TALXIS CLI (`txc`) is a modular, extensible .NET global tool for automating deve ## Table of Contents - [Installation](#installation) +- [Identity, Connections & Profiles](#identity-connections--profiles) - [Example Usage](#example-usage) - [Local Development & Debugging](#local-development--debugging) - [Versioning & Release](#versioning--release) @@ -50,43 +51,131 @@ After installation, use the CLI via the `txc` command in any terminal. --- +## Identity, Connections & Profiles + +`txc` decouples **who you are** (credentials) from **where you target** (connections) and exposes the combination as a named **profile**. Every command that touches a live environment takes exactly one context flag — `--profile ` (short form `-p`). There are no raw `--environment`, `--connection-string`, or `--device-code` flags on leaf commands; to switch endpoints or identities you create (or select) a different profile. + +The resolution order for the active profile is: + +``` +--profile flag > TXC_PROFILE env > /.txc/workspace.json > global active pointer (~/.txc/config.json) +``` + +Credentials never live in config files. Service-principal secrets, PATs, and certificate passwords are stored in the OS credential vault (DPAPI on Windows, Keychain on macOS, libsecret on Linux) and referenced from `credentials.json` by opaque `vault://` handles. MSAL tokens live in a separate cache file protected by the same vault. + +### Interactive workflow (dev laptop) + +**Quickstart — one command.** The common case (new laptop, new tenant) collapses to a single line: + +```sh +txc c p create --url https://contoso.crm4.dynamics.com/ +``` + +Behind the scenes `txc`: +1. Infers the provider from the URL host (`*.dynamics.com` → `dataverse`; gov/DoD/China endpoints covered too). +2. Derives a default profile/connection name from the first DNS label (`contoso` above). Override with `--name`. +3. Opens the browser for interactive sign-in, caches the MSAL token, and stores a credential keyed off your UPN. +4. Writes the `Credential`, `Connection`, and `Profile` records and auto-activates the new profile on first run. + +Result is identical to running the three primitive commands by hand — use whichever flow you prefer. + +**Advanced — primitives.** For scripted flows, service-principal onboarding, or reusing one credential across many environments: + +```sh +# 1. Log in interactively (opens a browser). Creates a Credential entry +# aliased from your UPN (override with --alias) and primes the MSAL cache. +txc c auth login + +# 2. Register the Dataverse environment you want to target. +txc c connection create customer-a-dev \ + --provider dataverse \ + --environment https://contoso.crm4.dynamics.com/ + +# 3. Bind credential + connection into a profile and select it. +txc c p create --name customer-a-dev \ + --auth \ + --connection customer-a-dev +txc c p select customer-a-dev + +# 4. Optional: pin this profile to the current repo. +# Writes /.txc/workspace.json so every shell in this checkout +# defaults to customer-a-dev without touching the global pointer. +txc c p pin +# Unpin when done: txc c p unpin + +# 5. Sanity-check end-to-end auth + endpoint reachability. +txc c p validate +``` + +### Headless / CI workflow (service principal) + +```sh +# Total config isolation — nothing written to $HOME on the runner. +export TXC_CONFIG_DIR="$RUNNER_TEMP/txc-config" +export TXC_NON_INTERACTIVE=1 + +# Secret is supplied via env var (never as --secret on the command line, +# which would leak to shell history and process listings). +export SPN_SECRET='' + +txc c auth add-service-principal \ + --tenant "$AZURE_TENANT_ID" \ + --client-id "$AZURE_CLIENT_ID" \ + --alias ci-spn \ + --secret-from-env SPN_SECRET + +txc c connection create ci-target \ + --provider dataverse \ + --environment "$DATAVERSE_URL" + +txc c p create --name ci --auth ci-spn --connection ci-target +txc c p select ci + +# Every subsequent txc call picks up TXC_CONFIG_DIR + the selected profile. +txc env pkg import TALXIS.Controls.FileExplorer.Package +``` + +For workload-identity federation (GitHub OIDC, Azure DevOps WIF), the Dataverse provider auto-detects `ACTIONS_ID_TOKEN_REQUEST_*` and `TXC_ADO_ID_TOKEN_REQUEST_*` env vars at acquire time — no extra flags needed. + +--- + ## Example Usage > [!IMPORTANT] > `txc` runs both **Dataverse Package Deployer** and **Configuration Migration Tool (CMT)** on **modern .NET**, including **macOS** and **Linux**. The goal is a better developer experience: cross-platform automation, simpler happy-path commands, and better visibility into what happened during deploys. +The examples below assume you have an active profile (see [above](#identity-connections--profiles)). Pass `--profile ` (or `-p `) to any command to override the active profile for a single invocation. + **Deploy the latest package from NuGet:** ```sh -txc env pkg import TALXIS.Controls.FileExplorer.Package \ - --environment https://org.crm.dynamics.com +txc env pkg import TALXIS.Controls.FileExplorer.Package ``` **Inspect the latest package deployment with findings:** ```sh -txc env deploy show --package-name TALXIS.Controls.FileExplorer.Package \ - --environment https://org.crm.dynamics.com +txc env deploy show --package-name TALXIS.Controls.FileExplorer.Package ``` **Uninstall a package from its source artifact:** ```sh -txc env pkg uninstall TALXIS.Controls.FileExplorer.Package \ - --yes \ - --environment https://org.crm.dynamics.com +txc env pkg uninstall TALXIS.Controls.FileExplorer.Package --yes ``` **Import a solution and follow the async operation when needed:** ```sh -txc env sln import ./Solutions/MySolution_managed.zip \ - --environment https://org.crm.dynamics.com +txc env sln import ./Solutions/MySolution_managed.zip + +txc env deploy show --async-operation-id +``` -txc env deploy show --async-operation-id \ - --environment https://org.crm.dynamics.com +**Target a different environment for a single call without switching profiles:** +```sh +txc env sln import ./Solutions/MySolution_managed.zip -p customer-b-prod ``` **Import a CMT data folder into Dataverse:** ```sh -txc data pkg import ./data-package \ - --environment https://org.crm.dynamics.com +txc data pkg import ./data-package ``` **Convert Excel to CMT XML:** diff --git a/TALXIS.CLI.sln b/TALXIS.CLI.sln index fe3692b..902a653 100644 --- a/TALXIS.CLI.sln +++ b/TALXIS.CLI.sln @@ -5,33 +5,35 @@ VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Data", "src\TALXIS.CLI.Data\TALXIS.CLI.Data.csproj", "{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Features.Data", "src\TALXIS.CLI.Features.Data\TALXIS.CLI.Features.Data.csproj", "{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Workspace", "src\TALXIS.CLI.Workspace\TALXIS.CLI.Workspace.csproj", "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Features.Workspace", "src\TALXIS.CLI.Features.Workspace\TALXIS.CLI.Features.Workspace.csproj", "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI", "src\TALXIS.CLI\TALXIS.CLI.csproj", "{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.MCP", "src\TALXIS.CLI.MCP\TALXIS.CLI.MCP.csproj", "{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Docs", "src\TALXIS.CLI.Docs\TALXIS.CLI.Docs.csproj", "{0914E284-15E7-4215-B72F-7195F0EB8EEA}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Features.Docs", "src\TALXIS.CLI.Features.Docs\TALXIS.CLI.Features.Docs.csproj", "{0914E284-15E7-4215-B72F-7195F0EB8EEA}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.IntegrationTests", "tests\TALXIS.CLI.IntegrationTests\TALXIS.CLI.IntegrationTests.csproj", "{EDB2D38C-8601-43BD-AC88-165E822986C7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.XrmTools.XrmShim", "src\TALXIS.CLI.XrmTools.XrmShim\TALXIS.CLI.XrmTools.XrmShim.csproj", "{FD7F08B2-53B4-4E31-8ACB-0BA0D26956C5}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Platform.XrmShim", "src\TALXIS.CLI.Platform.XrmShim\TALXIS.CLI.Platform.XrmShim.csproj", "{FD7F08B2-53B4-4E31-8ACB-0BA0D26956C5}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.XrmTools", "src\TALXIS.CLI.XrmTools\TALXIS.CLI.XrmTools.csproj", "{24CE8731-028D-4B4B-93C4-5FA2E6654BF7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Platform.Xrm", "src\TALXIS.CLI.Platform.Xrm\TALXIS.CLI.Platform.Xrm.csproj", "{24CE8731-028D-4B4B-93C4-5FA2E6654BF7}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Logging", "src\TALXIS.CLI.Logging\TALXIS.CLI.Logging.csproj", "{0B958420-EF8A-4B70-9E35-C273E712970E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Shared", "src\TALXIS.CLI.Shared\TALXIS.CLI.Shared.csproj", "{24931ECB-3396-491C-BEDA-BD90A944CCF8}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Tests", "tests\TALXIS.CLI.Tests\TALXIS.CLI.Tests.csproj", "{C88B9412-21F9-40E4-A2FE-D29AF57F780E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Dataverse", "src\TALXIS.CLI.Dataverse\TALXIS.CLI.Dataverse.csproj", "{409EE26E-EF1C-4C20-883D-A3EA6A0FD709}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Platform.Dataverse", "src\TALXIS.CLI.Platform.Dataverse\TALXIS.CLI.Platform.Dataverse.csproj", "{409EE26E-EF1C-4C20-883D-A3EA6A0FD709}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Features.Environment", "src\TALXIS.CLI.Features.Environment\TALXIS.CLI.Features.Environment.csproj", "{ED036063-8EE3-43BB-8380-3A114CE515FD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Core", "src\TALXIS.CLI.Core\TALXIS.CLI.Core.csproj", "{80DCD8C5-8577-424F-B4E2-9A1651A069D6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Environment", "src\TALXIS.CLI.Environment\TALXIS.CLI.Environment.csproj", "{ED036063-8EE3-43BB-8380-3A114CE515FD}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Features.Config", "src\TALXIS.CLI.Features.Config\TALXIS.CLI.Features.Config.csproj", "{5318996C-5E12-4C6B-9344-883CD9E2F4B7}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -151,18 +153,6 @@ Global {0B958420-EF8A-4B70-9E35-C273E712970E}.Release|x64.Build.0 = Release|Any CPU {0B958420-EF8A-4B70-9E35-C273E712970E}.Release|x86.ActiveCfg = Release|Any CPU {0B958420-EF8A-4B70-9E35-C273E712970E}.Release|x86.Build.0 = Release|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Debug|x64.ActiveCfg = Debug|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Debug|x64.Build.0 = Debug|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Debug|x86.ActiveCfg = Debug|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Debug|x86.Build.0 = Debug|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Release|Any CPU.Build.0 = Release|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Release|x64.ActiveCfg = Release|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Release|x64.Build.0 = Release|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Release|x86.ActiveCfg = Release|Any CPU - {24931ECB-3396-491C-BEDA-BD90A944CCF8}.Release|x86.Build.0 = Release|Any CPU {C88B9412-21F9-40E4-A2FE-D29AF57F780E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C88B9412-21F9-40E4-A2FE-D29AF57F780E}.Debug|Any CPU.Build.0 = Debug|Any CPU {C88B9412-21F9-40E4-A2FE-D29AF57F780E}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -199,6 +189,30 @@ Global {ED036063-8EE3-43BB-8380-3A114CE515FD}.Release|x64.Build.0 = Release|Any CPU {ED036063-8EE3-43BB-8380-3A114CE515FD}.Release|x86.ActiveCfg = Release|Any CPU {ED036063-8EE3-43BB-8380-3A114CE515FD}.Release|x86.Build.0 = Release|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Debug|x64.ActiveCfg = Debug|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Debug|x64.Build.0 = Debug|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Debug|x86.ActiveCfg = Debug|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Debug|x86.Build.0 = Debug|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Release|Any CPU.Build.0 = Release|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Release|x64.ActiveCfg = Release|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Release|x64.Build.0 = Release|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Release|x86.ActiveCfg = Release|Any CPU + {80DCD8C5-8577-424F-B4E2-9A1651A069D6}.Release|x86.Build.0 = Release|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Debug|x64.ActiveCfg = Debug|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Debug|x64.Build.0 = Debug|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Debug|x86.ActiveCfg = Debug|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Debug|x86.Build.0 = Debug|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Release|Any CPU.Build.0 = Release|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Release|x64.ActiveCfg = Release|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Release|x64.Build.0 = Release|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Release|x86.ActiveCfg = Release|Any CPU + {5318996C-5E12-4C6B-9344-883CD9E2F4B7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -213,10 +227,11 @@ Global {FD7F08B2-53B4-4E31-8ACB-0BA0D26956C5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {24CE8731-028D-4B4B-93C4-5FA2E6654BF7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {0B958420-EF8A-4B70-9E35-C273E712970E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {24931ECB-3396-491C-BEDA-BD90A944CCF8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C88B9412-21F9-40E4-A2FE-D29AF57F780E} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {409EE26E-EF1C-4C20-883D-A3EA6A0FD709} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {ED036063-8EE3-43BB-8380-3A114CE515FD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {80DCD8C5-8577-424F-B4E2-9A1651A069D6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {5318996C-5E12-4C6B-9344-883CD9E2F4B7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {53733BD6-A32A-41B7-9472-E377AF68151F} diff --git a/docs/mcp-http-auth-notes.md b/docs/mcp-http-auth-notes.md new file mode 100644 index 0000000..70204f2 --- /dev/null +++ b/docs/mcp-http-auth-notes.md @@ -0,0 +1,150 @@ +# `txc-mcp` HTTP transport — auth design notes (v1, design-only) + +**Status: design-only. No HTTP transport code ships in v1.** +This document records the design decisions that will apply when the MCP +HTTP/SSE transport is eventually added, so the current stdio +implementation does not paint us into a corner. It is intentionally +short on step-by-step how-to (that belongs in whichever PR lands the +transport) and heavy on invariants + forbidden patterns. + +Baseline spec: [MCP 2025-06-18 specification](https://modelcontextprotocol.io/specification). + +--- + +## 1. Role: resource server only + +When `txc-mcp` exposes an HTTP/SSE transport, it will be an **OAuth 2.1 +resource server** — **never** an authorization server. + +- Authorization server = **Entra ID** (the user's tenant). +- Resource server = `txc-mcp`. +- `txc-mcp` does **not** expose `/authorize`, `/token`, `/device_code`, + `/introspect`, or any other AS endpoint. If a client needs a token, + it negotiates directly with Entra; `txc-mcp` only validates. + +## 2. Discovery + +Expose [RFC 9728 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) +at a well-known URL: + +``` +GET /.well-known/oauth-protected-resource +``` + +Response MUST include: + +- `resource` — canonical URI for this MCP server (e.g. + `https:///mcp`). Clients use this as the `resource` parameter + ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)) when + requesting audience-bound tokens. +- `authorization_servers` — list of Entra tenant issuers accepted. +- `bearer_methods_supported` — `["header"]` only. Tokens never in + URIs or cookies. +- `scopes_supported` — the fine-grained scopes the server recognizes + (TBD; current provider scope is `{resource}//.default` for + Dataverse, but HTTP may slice finer). + +## 3. Token validation + +Every inbound request on the HTTP transport: + +1. Must carry `Authorization: Bearer `. +2. JWT MUST be validated: + - `iss` matches one of the advertised `authorization_servers`, + - `aud` equals the canonical `resource` URI (RFC 8707 audience + binding; reject bearer tokens minted for other resources even + if signed by the same tenant), + - `exp` / `nbf` in window, + - signature via JWKS fetched from the issuer's metadata. +3. On failure, respond `401 Unauthorized` with: + + ``` + WWW-Authenticate: Bearer resource_metadata="https:///.well-known/oauth-protected-resource" + ``` + + so the client can discover the correct authorization server and + retry with a properly audience-bound token. + +## 4. Per-session state + +- Each session is identified by `Mcp-Session-Id` (server-minted, + cryptographically random; never the client's bearer jti). +- Client MUST include `MCP-Protocol-Version: 2025-06-18` on every + request; reject with `400` if missing or unsupported. +- Session cache holds `(sessionId, profile) -> resolved credentials` + (see §5). On `404 Session Not Found` — e.g. server restart, idle + eviction — purge the cache entry immediately to prevent stale + credential reuse across session re-issues. + +## 5. Credential resolution with a per-call `profile` argument + +The stdio transport already accepts an optional `profile` tool argument +on every `ProfiledCliCommand`-derived tool. HTTP keeps the same shape: + +``` +resolve(sessionId, profile) -> Credential +``` + +- `sessionId` identifies the user bound to the inbound bearer token. +- `profile` (optional) selects which stored `Profile` to use for this + call. + +This matters because a single MCP session may target multiple +downstream environments (e.g. customer-a-dev and customer-b-prod) +without re-authenticating to `txc-mcp`. The axis already exists in +v1; HTTP just makes `sessionId` non-constant. + +## 6. Forbidden patterns (hard stops) + +All of these are explicitly banned by the MCP spec and must be +rejected at code-review time, not debugged in prod: + +| Anti-pattern | Why it's forbidden | +|---|---| +| **Token passthrough** — forwarding the client's inbound bearer directly to Dataverse or any downstream API. | Client bearer is audience-bound to `txc-mcp` (RFC 8707). Dataverse will reject it, and even if it didn't, it would let any leaf upstream bypass `txc-mcp`'s authorization entirely. Use on-behalf-of (OBO) flow or a separate service account. | +| **Tokens in URIs** — `?access_token=...`, path segments, etc. | Proxies and access logs leak URIs. `Authorization` header only. | +| **Non-HTTPS redirects** outside loopback. | Prevents MITM on OAuth redirects. Loopback `http://127.0.0.1:/...` is allowed for local dev. | +| **Missing PKCE** on any OAuth flow. | Required by OAuth 2.1 for all public clients; we enforce for confidential clients too. | +| **Missing audience check** on inbound tokens. | Without it, any Entra-signed token for the same tenant would work — violates least privilege and opens lateral-movement vectors. | +| **Long-lived session cache of federation tokens** (ADO WIF, GitHub OIDC). | Those tokens are short-lived and non-refreshable. Cache MUST be keyed on `(tenantId, upn, profileId)` and re-acquired per call. | + +## 7. Transport-security defaults + +- Bind to `127.0.0.1` only by default. Explicit `--bind 0.0.0.0` (+ a + `--yes-i-know-this-is-a-remote-bind` acknowledgement flag) to + expose on LAN. +- Validate `Origin` on every request; reject mismatches with `403`. +- HSTS (`Strict-Transport-Security: max-age=31536000`) on all + TLS-terminated responses. +- CORS: disabled by default. Browser-based MCP clients are an + explicit opt-in per deployment. + +## 8. Log redaction invariants carry over + +Everything `JsonStderrLogger` + `LogRedactionFilter` already redact on +stdio (`Bearer `, `Authorization:`, bare JWTs, connection-string +secret keys, URL query-param secrets) also applies when HTTP logs flow +through `McpLogForwarder`. Do not introduce a second logging sink that +bypasses the redaction filter. + +## 9. What this design deliberately does NOT do (v1 and beyond) + +- No custom token store on the server side. All tokens live in MSAL + token cache (user machine) or the Entra AS (remote). +- No "refresh token rotation" on behalf of the client. Client handles + its own refreshes; we only validate. +- No built-in rate limiter. Ship behind a reverse proxy that handles + throttling. +- No OAuth device flow support. Device flow is for interactive + end-user login; MCP HTTP is M2M/service-to-service. + +--- + +## References + +- [MCP 2025-06-18 spec](https://modelcontextprotocol.io/specification) +- [RFC 8707 — Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) +- [RFC 9728 — Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) +- [OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) +- `temp/mcp-auth-research.md` — research notes that informed this design. +- `src/TALXIS.CLI.MCP/README.md` — stdio transport auth contract (already live). diff --git a/src/TALXIS.CLI.Core/Abstractions/IConfigurationResolver.cs b/src/TALXIS.CLI.Core/Abstractions/IConfigurationResolver.cs new file mode 100644 index 0000000..14a40f5 --- /dev/null +++ b/src/TALXIS.CLI.Core/Abstractions/IConfigurationResolver.cs @@ -0,0 +1,26 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Abstractions; + +/// +/// Resolves the (Profile, Connection, Credential) triple for a given invocation. +/// Precedence (highest first): +/// +/// Explicit argument (command-line). +/// TXC_PROFILE environment variable. +/// Workspace .txc/workspace.json (cwd-walk). +/// Global active-profile pointer in config.json. +/// Ephemeral context from environment variables (no stored profile). +/// +/// Throws when none of the layers yield a context. +/// +public interface IConfigurationResolver +{ + Task ResolveAsync(string? profileName, CancellationToken ct); +} + +public sealed class ConfigurationResolutionException : Exception +{ + public ConfigurationResolutionException(string message) : base(message) { } + public ConfigurationResolutionException(string message, Exception inner) : base(message, inner) { } +} diff --git a/src/TALXIS.CLI.Core/Abstractions/IConnectionProvider.cs b/src/TALXIS.CLI.Core/Abstractions/IConnectionProvider.cs new file mode 100644 index 0000000..532f443 --- /dev/null +++ b/src/TALXIS.CLI.Core/Abstractions/IConnectionProvider.cs @@ -0,0 +1,27 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Abstractions; + +/// Depth of a check. +public enum ValidationMode +{ + /// Pure shape check — URLs, credential-kind compatibility, authority wiring. + Structural, + + /// Structural, plus a live authenticated round-trip (e.g. WhoAmI). + Live, +} + +/// +/// Connects a Connection + Credential pair to a runtime service client. +/// Registered once per provider; the Dataverse implementation lives in +/// TALXIS.CLI.Platform.Dataverse. +/// +public interface IConnectionProvider +{ + ProviderKind ProviderKind { get; } + IReadOnlySet SupportedCredentialKinds { get; } + + /// Validate that the connection + credential can be used. Live mode issues a real request (e.g. WhoAmI for Dataverse). + Task ValidateAsync(Connection connection, Credential credential, ValidationMode mode, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Abstractions/ICredentialVault.cs b/src/TALXIS.CLI.Core/Abstractions/ICredentialVault.cs new file mode 100644 index 0000000..b8f10d5 --- /dev/null +++ b/src/TALXIS.CLI.Core/Abstractions/ICredentialVault.cs @@ -0,0 +1,14 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Abstractions; + +/// +/// OS-level secret vault (DPAPI / Keychain / libsecret) holding material +/// referenced by . +/// +public interface ICredentialVault +{ + Task GetSecretAsync(SecretRef reference, CancellationToken ct); + Task SetSecretAsync(SecretRef reference, string value, CancellationToken ct); + Task DeleteSecretAsync(SecretRef reference, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Abstractions/IHeadlessDetector.cs b/src/TALXIS.CLI.Core/Abstractions/IHeadlessDetector.cs new file mode 100644 index 0000000..88cb391 --- /dev/null +++ b/src/TALXIS.CLI.Core/Abstractions/IHeadlessDetector.cs @@ -0,0 +1,36 @@ +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Abstractions; + +/// +/// Reports whether the current process is running in a non-interactive +/// (headless / CI) context. Used to forbid interactive and device-code +/// authentication flows outside TTY sessions. +/// +public interface IHeadlessDetector +{ + bool IsHeadless { get; } + + /// Human-readable reason for the last determination (e.g. "CI=true", "stdin redirected"). + string? Reason { get; } +} + +/// +/// Standard fail-fast extensions for . +/// +public static class HeadlessDetectorExtensions +{ + /// + /// Throws if the process is + /// headless and is not in + /// . + /// + public static void EnsureKindAllowed(this IHeadlessDetector detector, CredentialKind kind) + { + ArgumentNullException.ThrowIfNull(detector); + if (!detector.IsHeadless) return; + if (HeadlessAuthRequiredException.PermittedHeadlessKinds.Contains(kind)) return; + throw new HeadlessAuthRequiredException(kind, detector.Reason ?? "non-interactive environment"); + } +} diff --git a/src/TALXIS.CLI.Core/Abstractions/IInteractiveLoginService.cs b/src/TALXIS.CLI.Core/Abstractions/IInteractiveLoginService.cs new file mode 100644 index 0000000..1960e52 --- /dev/null +++ b/src/TALXIS.CLI.Core/Abstractions/IInteractiveLoginService.cs @@ -0,0 +1,34 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Abstractions; + +/// +/// Result of an interactive browser sign-in. The token itself stays in +/// the MSAL token cache; only the account identity leaves this boundary +/// so config auth login can persist a matching +/// . +/// +public sealed record InteractiveLoginResult(string Upn, string TenantId); + +/// +/// Performs an eager interactive browser sign-in against Entra, primes the +/// MSAL token cache, and returns the signed-in account identity. +/// +/// +/// In v1 this is Dataverse-flavoured (pinned pac public client id + its +/// sovereign authority map). When further providers land, each provider +/// registers its own implementation keyed by . +/// +public interface IInteractiveLoginService +{ + /// + /// Optional Entra tenant id or domain. When null, the + /// organizations endpoint is used and the tenant is inferred + /// from whichever account the user picks in the browser. + /// + /// Sovereign cloud for the authority URL. + Task LoginAsync( + string? tenantId, + CloudInstance cloud, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Abstractions/IStores.cs b/src/TALXIS.CLI.Core/Abstractions/IStores.cs new file mode 100644 index 0000000..4d665f8 --- /dev/null +++ b/src/TALXIS.CLI.Core/Abstractions/IStores.cs @@ -0,0 +1,33 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Abstractions; + +public interface IProfileStore +{ + Task> ListAsync(CancellationToken ct); + Task GetAsync(string id, CancellationToken ct); + Task UpsertAsync(Profile profile, CancellationToken ct); + Task DeleteAsync(string id, CancellationToken ct); +} + +public interface IConnectionStore +{ + Task> ListAsync(CancellationToken ct); + Task GetAsync(string id, CancellationToken ct); + Task UpsertAsync(Connection connection, CancellationToken ct); + Task DeleteAsync(string id, CancellationToken ct); +} + +public interface ICredentialStore +{ + Task> ListAsync(CancellationToken ct); + Task GetAsync(string id, CancellationToken ct); + Task UpsertAsync(Credential credential, CancellationToken ct); + Task DeleteAsync(string id, CancellationToken ct); +} + +public interface IGlobalConfigStore +{ + Task LoadAsync(CancellationToken ct); + Task SaveAsync(GlobalConfig config, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Abstractions/IWorkspaceDiscovery.cs b/src/TALXIS.CLI.Core/Abstractions/IWorkspaceDiscovery.cs new file mode 100644 index 0000000..aecc9dc --- /dev/null +++ b/src/TALXIS.CLI.Core/Abstractions/IWorkspaceDiscovery.cs @@ -0,0 +1,14 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Abstractions; + +/// +/// Walks the filesystem upward from startDirectory looking for +/// .txc/workspace.json. First hit wins; returns null if none found. +/// +public interface IWorkspaceDiscovery +{ + Task DiscoverAsync(string startDirectory, CancellationToken ct); +} + +public sealed record WorkspaceResolution(string WorkspaceRoot, string WorkspaceFilePath, WorkspaceConfig Config); diff --git a/src/TALXIS.CLI.Core/Bootstrapping/ConnectionUpsertService.cs b/src/TALXIS.CLI.Core/Bootstrapping/ConnectionUpsertService.cs new file mode 100644 index 0000000..e108f7c --- /dev/null +++ b/src/TALXIS.CLI.Core/Bootstrapping/ConnectionUpsertService.cs @@ -0,0 +1,84 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Core.Bootstrapping; + +/// +/// Outcome of . +/// Exceptions are reserved for unexpected store failures; input-validation +/// errors flow through so callers can translate to +/// their preferred exit code without parsing exception messages. +/// +public sealed record ConnectionUpsertResult(Connection? Connection, string? Error); + +/// +/// Shared "validate + upsert a Dataverse connection" logic. Extracted +/// from ConnectionCreateCliCommand so the one-liner bootstrap +/// (profile create --url) writes connections through the same +/// validator — any rule that changes here changes for both entry points. +/// +public sealed class ConnectionUpsertService +{ + private readonly IConnectionStore _store; + + public ConnectionUpsertService(IConnectionStore store) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + /// + /// Validates Dataverse connection inputs, normalises the URL, and + /// upserts via . Returns the persisted + /// model on success or a user-facing error string on invalid input. + /// + public async Task ValidateAndUpsertAsync( + string name, + ProviderKind provider, + string? environmentUrl, + CloudInstance? cloud, + string? organizationId, + string? tenantId, + string? description, + CancellationToken ct) + { + var trimmed = name?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + return new ConnectionUpsertResult(null, "Connection name must not be empty."); + + if (provider != ProviderKind.Dataverse) + return new ConnectionUpsertResult(null, + $"Provider '{provider}' is not implemented in v1. Only 'dataverse' is supported."); + + if (string.IsNullOrWhiteSpace(environmentUrl)) + return new ConnectionUpsertResult(null, + "--environment is required when --provider is 'dataverse'."); + + if (!Uri.TryCreate(environmentUrl, UriKind.Absolute, out var envUri) + || (envUri.Scheme != Uri.UriSchemeHttp && envUri.Scheme != Uri.UriSchemeHttps)) + { + return new ConnectionUpsertResult(null, + $"--environment must be an absolute http(s) URL: '{environmentUrl}'."); + } + + if (!string.IsNullOrWhiteSpace(organizationId) && !Guid.TryParse(organizationId, out _)) + { + return new ConnectionUpsertResult(null, + $"--organization-id must be a GUID: '{organizationId}'."); + } + + var connection = new Connection + { + Id = trimmed!, + Provider = provider, + Description = description, + EnvironmentUrl = envUri.ToString().TrimEnd('/'), + Cloud = cloud ?? CloudInstance.Public, + OrganizationId = organizationId, + TenantId = tenantId, + }; + + await _store.UpsertAsync(connection, ct).ConfigureAwait(false); + return new ConnectionUpsertResult(connection, null); + } +} diff --git a/src/TALXIS.CLI.Core/Bootstrapping/CredentialAliasResolver.cs b/src/TALXIS.CLI.Core/Bootstrapping/CredentialAliasResolver.cs new file mode 100644 index 0000000..79ec339 --- /dev/null +++ b/src/TALXIS.CLI.Core/Bootstrapping/CredentialAliasResolver.cs @@ -0,0 +1,95 @@ +using TALXIS.CLI.Core.Abstractions; + +namespace TALXIS.CLI.Core.Bootstrapping; + +/// +/// Derives a collision-free credential alias from a UPN. Extracted from +/// AuthLoginCliCommand so the one-liner bootstrap +/// (profile create --url) can reuse the exact same rule set — if +/// two commands can create credentials, they must agree on the default +/// alias or users end up with silent duplicates. +/// +public static class CredentialAliasResolver +{ + /// + /// Derives an alias from the UPN. Falls back to appending the UPN's + /// tenant-domain short name, then numeric suffixes, until an unused + /// alias is found. + /// + public static async Task ResolveForUpnAsync( + ICredentialStore store, string upn, CancellationToken ct) + { + if (store is null) throw new ArgumentNullException(nameof(store)); + if (string.IsNullOrWhiteSpace(upn)) throw new ArgumentException("UPN must not be empty.", nameof(upn)); + + var slug = upn.Trim().ToLowerInvariant(); + if (await store.GetAsync(slug, ct).ConfigureAwait(false) is null) + return slug; + + var shortName = ExtractTenantShortName(upn); + if (!string.IsNullOrEmpty(shortName)) + { + var combined = $"{slug}-{shortName}"; + if (await store.GetAsync(combined, ct).ConfigureAwait(false) is null) + return combined; + } + + // Numeric fallback caps at 99 — past that the user should pass an explicit alias. + for (var i = 2; i < 100; i++) + { + var candidate = $"{slug}-{i}"; + if (await store.GetAsync(candidate, ct).ConfigureAwait(false) is null) + return candidate; + } + + throw new InvalidOperationException( + $"Cannot derive a unique alias for '{upn}' — pass an explicit alias."); + } + + /// + /// Returns the first domain label from the UPN in lowercase, e.g. + /// tomas@contoso.comcontoso. Returns null + /// when the UPN has no usable domain portion. + /// + public static string? ExtractTenantShortName(string upn) + { + if (string.IsNullOrWhiteSpace(upn)) return null; + var at = upn.IndexOf('@'); + if (at < 0 || at == upn.Length - 1) return null; + + var domain = upn[(at + 1)..]; + var dot = domain.IndexOf('.'); + var head = dot > 0 ? domain[..dot] : domain; + head = head.Trim().ToLowerInvariant(); + return string.IsNullOrEmpty(head) ? null : head; + } + + /// + /// Returns if free, otherwise the + /// first numeric-suffixed variant (-2, -3, …) that + /// passes . Caps at 99 to avoid infinite + /// loops on misbehaving stores. + /// + public static async Task ResolveFreeNameAsync( + string preferredBase, + Func> exists, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(preferredBase)) + throw new ArgumentException("Name base must not be empty.", nameof(preferredBase)); + if (exists is null) throw new ArgumentNullException(nameof(exists)); + + if (!await exists(preferredBase, ct).ConfigureAwait(false)) + return preferredBase; + + for (var i = 2; i < 100; i++) + { + var candidate = $"{preferredBase}-{i}"; + if (!await exists(candidate, ct).ConfigureAwait(false)) + return candidate; + } + + throw new InvalidOperationException( + $"Cannot derive a unique name starting from '{preferredBase}' — pass an explicit name."); + } +} diff --git a/src/TALXIS.CLI.Core/Bootstrapping/IConnectionProviderBootstrapper.cs b/src/TALXIS.CLI.Core/Bootstrapping/IConnectionProviderBootstrapper.cs new file mode 100644 index 0000000..fcbe987 --- /dev/null +++ b/src/TALXIS.CLI.Core/Bootstrapping/IConnectionProviderBootstrapper.cs @@ -0,0 +1,43 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Bootstrapping; + +/// +/// Inputs to . +/// Captures everything needed to stand up a credential + connection pair +/// in one shot. The bootstrapper owns interactive login (if any) and the +/// upsert of both and . +/// +public sealed record ProfileBootstrapRequest( + string Name, + ProviderKind Provider, + string EnvironmentUrl, + CloudInstance Cloud, + string? TenantId, + string? Description); + +/// +/// Outcome of a bootstrap attempt. is populated on +/// validation / non-exceptional failure so the caller can translate into +/// its chosen exit code. Unexpected failures still throw. +/// +public sealed record ProfileBootstrapResult( + Credential? Credential, + Connection? Connection, + string? Upn, + string? Error); + +/// +/// Provider-scoped orchestrator for the profile create --url +/// one-liner. Composes interactive login + alias resolution + credential +/// upsert + connection upsert into a single step so that there is exactly +/// one path that writes these primitives from a URL (the primitive +/// commands delegate to the same helpers — no duplicated rules). +/// +public interface IConnectionProviderBootstrapper +{ + ProviderKind Provider { get; } + + Task BootstrapAsync( + ProfileBootstrapRequest request, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Bootstrapping/InteractiveCredentialBootstrapper.cs b/src/TALXIS.CLI.Core/Bootstrapping/InteractiveCredentialBootstrapper.cs new file mode 100644 index 0000000..2993e4a --- /dev/null +++ b/src/TALXIS.CLI.Core/Bootstrapping/InteractiveCredentialBootstrapper.cs @@ -0,0 +1,59 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Core.Bootstrapping; + +/// +/// Outcome of . +/// +public sealed record InteractiveCredentialResult(Credential Credential, string Upn, string TenantId); + +/// +/// Single path that performs an interactive browser sign-in and persists +/// the resulting . Used both by the primitive +/// auth login command and by the Dataverse one-liner bootstrapper +/// — they must agree on headless policy, alias derivation, and credential +/// shape, so neither duplicates the other. +/// +public static class InteractiveCredentialBootstrapper +{ + /// + /// Enforces the headless policy for interactive browser sign-in, + /// runs the login, resolves an alias (explicit override or UPN-derived), + /// and upserts the credential. + /// + public static async Task AcquireAndPersistAsync( + IInteractiveLoginService login, + ICredentialStore store, + IHeadlessDetector headless, + string? tenantId, + CloudInstance cloud, + string? explicitAlias, + CancellationToken ct) + { + if (login is null) throw new ArgumentNullException(nameof(login)); + if (store is null) throw new ArgumentNullException(nameof(store)); + if (headless is null) throw new ArgumentNullException(nameof(headless)); + + headless.EnsureKindAllowed(CredentialKind.InteractiveBrowser); + + var result = await login.LoginAsync(tenantId, cloud, ct).ConfigureAwait(false); + + var alias = string.IsNullOrWhiteSpace(explicitAlias) + ? await CredentialAliasResolver.ResolveForUpnAsync(store, result.Upn, ct).ConfigureAwait(false) + : explicitAlias!.Trim(); + + var credential = new Credential + { + Id = alias, + Kind = CredentialKind.InteractiveBrowser, + TenantId = result.TenantId, + Cloud = cloud, + Description = $"Interactive sign-in ({result.Upn})", + }; + await store.UpsertAsync(credential, ct).ConfigureAwait(false); + return new InteractiveCredentialResult(credential, result.Upn, result.TenantId); + } +} diff --git a/src/TALXIS.CLI.Core/Bootstrapping/ProviderUrlResolver.cs b/src/TALXIS.CLI.Core/Bootstrapping/ProviderUrlResolver.cs new file mode 100644 index 0000000..eb850a0 --- /dev/null +++ b/src/TALXIS.CLI.Core/Bootstrapping/ProviderUrlResolver.cs @@ -0,0 +1,104 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Bootstrapping; + +/// +/// Infers a from a service URL host, so the +/// one-liner profile create --url can accept a single URL and +/// figure out the rest. Table-driven — future providers (jira, devops) +/// plug in with a single entry. +/// +/// +/// Rules are evaluated top-down. First match wins. An unknown host +/// returns null and the caller is expected to demand +/// --provider with an actionable error that lists the known +/// host suffixes. +/// +public static class ProviderUrlResolver +{ + public sealed record HostSuffixRule(string Suffix, ProviderKind Provider); + + /// + /// Commercial + sovereign Dataverse host suffixes. Gov / DoD / China + /// endpoints share the same provider — the sovereign + /// is resolved separately by the Dataverse authority map. + /// + public static IReadOnlyList DefaultRules { get; } = new List + { + new(".dynamics.com", ProviderKind.Dataverse), + new(".crm.microsoftdynamics.us", ProviderKind.Dataverse), + new(".crm.appsplatform.us", ProviderKind.Dataverse), + new(".dynamics.cn", ProviderKind.Dataverse), + // future providers land here — one line each: + // new(".atlassian.net", ProviderKind.Jira), + }; + + public sealed record InferenceResult(ProviderKind? Provider, string? Error); + + /// + /// Parses and returns the matching provider + /// (or null+error for unknown hosts / malformed URLs). + /// + public static InferenceResult Infer(string? url, IReadOnlyList? rules = null) + { + if (string.IsNullOrWhiteSpace(url)) + return new InferenceResult(null, "URL must not be empty."); + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + return new InferenceResult(null, $"'{url}' is not an absolute http(s) URL."); + + var host = uri.Host.ToLowerInvariant(); + foreach (var rule in rules ?? DefaultRules) + { + if (host.EndsWith(rule.Suffix, StringComparison.Ordinal)) + return new InferenceResult(rule.Provider, null); + } + + var known = string.Join(", ", (rules ?? DefaultRules).Select(r => $"*{r.Suffix}")); + return new InferenceResult(null, + $"Cannot infer provider from host '{uri.Host}'. Pass --provider explicitly. Known host suffixes: {known}."); + } + + /// + /// Derives a default profile/connection name from the URL host's + /// first DNS label (https://contoso.crm4.dynamics.com/ → + /// contoso). Lowercased; non-alphanumeric runs collapsed to + /// -; trimmed to 64 chars. Returns null if the URL + /// is malformed or yields no usable label. + /// + public static string? DeriveDefaultName(string? url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return null; + + var host = uri.Host; + if (string.IsNullOrEmpty(host)) return null; + var dot = host.IndexOf('.'); + var head = dot > 0 ? host[..dot] : host; + + var buf = new System.Text.StringBuilder(head.Length); + var lastDash = true; + foreach (var c in head.ToLowerInvariant()) + { + if (char.IsLetterOrDigit(c)) + { + buf.Append(c); + lastDash = false; + } + else if (!lastDash && buf.Length > 0) + { + buf.Append('-'); + lastDash = true; + } + } + while (buf.Length > 0 && buf[^1] == '-') + buf.Length--; + + if (buf.Length == 0) return null; + if (buf.Length > 64) buf.Length = 64; + while (buf.Length > 0 && buf[^1] == '-') + buf.Length--; + return buf.Length == 0 ? null : buf.ToString(); + } +} diff --git a/src/TALXIS.CLI.Core/DependencyInjection/ConfigServiceCollectionExtensions.cs b/src/TALXIS.CLI.Core/DependencyInjection/ConfigServiceCollectionExtensions.cs new file mode 100644 index 0000000..e712fce --- /dev/null +++ b/src/TALXIS.CLI.Core/DependencyInjection/ConfigServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Core.Vault; + +namespace TALXIS.CLI.Core.DependencyInjection; + +public static class ConfigServiceCollectionExtensions +{ + /// + /// Registers the txc config core services (stores, resolver, workspace discovery, + /// headless detector, OS-backed credential vault). + /// + public static IServiceCollection AddTxcConfigCore(this IServiceCollection services) + { + services.AddSingleton(_ => ConfigPaths.FromEnvironment()); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(ProcessEnvironmentReader.Instance); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Singleton so MsalCacheHelper (and its CrossPlatLock) is instantiated + // once per process per cache file. See `session/files/keychain-prompt-research.md`: + // each extra instantiation is an extra Keychain prompt on macOS. + services.AddSingleton(sp => + { + var paths = sp.GetRequiredService(); + var env = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + return MsalBackedCredentialVault + .CreateAsync(paths, env, logger) + .GetAwaiter().GetResult(); + }); + + return services; + } +} diff --git a/src/TALXIS.CLI.Core/DependencyInjection/TxcServices.cs b/src/TALXIS.CLI.Core/DependencyInjection/TxcServices.cs new file mode 100644 index 0000000..7284d6b --- /dev/null +++ b/src/TALXIS.CLI.Core/DependencyInjection/TxcServices.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace TALXIS.CLI.Core.DependencyInjection; + +/// +/// Process-wide service locator. is called once from +/// Program.Main so every [CliCommand] handler can resolve services +/// without fighting the command framework for constructor injection. This is +/// the single bootstrap entry point used both by the normal CLI pipeline and +/// by the __txc_internal_package_deployer subprocess branch. +/// +public static class TxcServices +{ + private static IServiceProvider? _provider; + private static readonly object _gate = new(); + + /// + /// Installs the process-wide service provider. Fails fast if called twice — + /// double-initialization means two composition roots are fighting for the + /// same locator and would resolve services from different containers. + /// Tests that need to rebuild the container must call + /// between initializations. + /// + public static void Initialize(IServiceProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + lock (_gate) + { + if (_provider is not null && !ReferenceEquals(_provider, provider)) + throw new InvalidOperationException( + "TxcServices.Initialize has already been called. Only one composition root is allowed per process; call TxcServices.Reset() first if you intentionally want to replace it (tests only)."); + _provider = provider; + } + } + + public static bool IsInitialized => _provider is not null; + + public static T Get() where T : notnull + { + if (_provider is null) + throw new InvalidOperationException("TxcServices.Initialize has not been called."); + return _provider.GetRequiredService(); + } + + public static T? GetOptional() where T : class + { + return _provider?.GetService(); + } + + public static IEnumerable GetAll() where T : notnull + { + if (_provider is null) + throw new InvalidOperationException("TxcServices.Initialize has not been called."); + return _provider.GetServices(); + } + + // Exposed only for test teardown. + internal static void Reset() + { + lock (_gate) + { + _provider = null; + } + } +} diff --git a/src/TALXIS.CLI.Core/Headless/HeadlessAuthRequiredException.cs b/src/TALXIS.CLI.Core/Headless/HeadlessAuthRequiredException.cs new file mode 100644 index 0000000..fca5ac0 --- /dev/null +++ b/src/TALXIS.CLI.Core/Headless/HeadlessAuthRequiredException.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Headless; + +/// +/// Thrown when an interactive-only authentication flow is attempted in a +/// headless / CI context. Carries a deterministic user-facing remedy that +/// lists every permitted credential kind plus the exact env vars / profile +/// commands needed to re-run non-interactively. +/// +public sealed class HeadlessAuthRequiredException : Exception +{ + /// Credential kinds that are permitted when is true. + public static IReadOnlySet PermittedHeadlessKinds { get; } = + new HashSet + { + CredentialKind.ClientSecret, + CredentialKind.ClientCertificate, + CredentialKind.ManagedIdentity, + CredentialKind.WorkloadIdentityFederation, + CredentialKind.AzureCli, + CredentialKind.Pat, + }; + + public CredentialKind AttemptedKind { get; } + public string HeadlessReason { get; } + + public HeadlessAuthRequiredException(CredentialKind attemptedKind, string headlessReason) + : base(BuildMessage(attemptedKind, headlessReason)) + { + AttemptedKind = attemptedKind; + HeadlessReason = headlessReason; + } + + private static string BuildMessage(CredentialKind kind, string reason) + { + var permitted = string.Join(", ", + PermittedHeadlessKinds + .Select(ToKebab) + .OrderBy(s => s, StringComparer.Ordinal)); + + return + $"Credential kind '{ToKebab(kind)}' requires an interactive TTY, " + + $"but this process is running in headless mode ({reason}). " + + $"Permitted headless kinds: {permitted}. " + + "To run non-interactively, register a headless-capable credential with " + + "`txc config auth add-service-principal --alias --tenant " + + "--client-id --secret-from-env ` and bind it to the profile, " + + "or supply the credential via environment variables " + + "(AZURE_CLIENT_ID / AZURE_CLIENT_SECRET / AZURE_TENANT_ID for SPN, " + + "AZURE_FEDERATED_TOKEN_FILE for workload-identity federation)."; + } + + private static string ToKebab(CredentialKind kind) + => JsonNamingPolicy.KebabCaseLower.ConvertName(kind.ToString()); +} diff --git a/src/TALXIS.CLI.Core/Headless/HeadlessDetector.cs b/src/TALXIS.CLI.Core/Headless/HeadlessDetector.cs new file mode 100644 index 0000000..287e30f --- /dev/null +++ b/src/TALXIS.CLI.Core/Headless/HeadlessDetector.cs @@ -0,0 +1,57 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Internal; +using TALXIS.CLI.Core.Resolution; + +namespace TALXIS.CLI.Core.Headless; + +/// +/// Determines whether the current process is running without a usable TTY. +/// Interactive and device-code authentication flows are forbidden when +/// is true. +/// +public sealed class HeadlessDetector : IHeadlessDetector +{ + public const string TxcNonInteractive = "TXC_NON_INTERACTIVE"; + + private static readonly string[] CiVariables = + { + "CI", "GITHUB_ACTIONS", "TF_BUILD", + }; + + public HeadlessDetector() : this(new ConsoleRedirectionProbe(), ProcessEnvironmentReader.Instance) { } + + internal HeadlessDetector(IConsoleRedirectionProbe probe, IEnvironmentReader env) + { + var reasons = new List(); + + if (EnvBool.IsTruthy(env.Get(TxcNonInteractive))) + reasons.Add($"{TxcNonInteractive}=1"); + + foreach (var ci in CiVariables) + { + if (EnvBool.IsTruthy(env.Get(ci))) + reasons.Add($"{ci}={env.Get(ci)}"); + } + + if (probe.IsInputRedirected && probe.IsOutputRedirected) + reasons.Add("stdin and stdout are redirected"); + + IsHeadless = reasons.Count > 0; + Reason = IsHeadless ? string.Join("; ", reasons) : null; + } + + public bool IsHeadless { get; } + public string? Reason { get; } +} + +internal interface IConsoleRedirectionProbe +{ + bool IsInputRedirected { get; } + bool IsOutputRedirected { get; } +} + +internal sealed class ConsoleRedirectionProbe : IConsoleRedirectionProbe +{ + public bool IsInputRedirected => Console.IsInputRedirected; + public bool IsOutputRedirected => Console.IsOutputRedirected; +} diff --git a/src/TALXIS.CLI.Core/Internal/EnvBool.cs b/src/TALXIS.CLI.Core/Internal/EnvBool.cs new file mode 100644 index 0000000..3b8bc2a --- /dev/null +++ b/src/TALXIS.CLI.Core/Internal/EnvBool.cs @@ -0,0 +1,17 @@ +namespace TALXIS.CLI.Core.Internal; + +/// +/// Shared parser for truthy env-var values (1 / true / yes, +/// case-insensitive). Consolidates duplicates previously in +/// HeadlessDetector and VaultOptions. +/// +internal static class EnvBool +{ + public static bool IsTruthy(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + return value.Equals("1", StringComparison.Ordinal) + || value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/TALXIS.CLI.Core/Model/CloudInstance.cs b/src/TALXIS.CLI.Core/Model/CloudInstance.cs new file mode 100644 index 0000000..f3d8cd8 --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/CloudInstance.cs @@ -0,0 +1,14 @@ +namespace TALXIS.CLI.Core.Model; + +/// +/// Sovereign cloud instances recognised by txc. Maps to Dataverse/Entra authorities +/// in TALXIS.CLI.Platform.Dataverse. +/// +public enum CloudInstance +{ + Public, + Gcc, + GccHigh, + Dod, + China, +} diff --git a/src/TALXIS.CLI.Core/Model/Connection.cs b/src/TALXIS.CLI.Core/Model/Connection.cs new file mode 100644 index 0000000..1879362 --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/Connection.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TALXIS.CLI.Core.Model; + +/// +/// Service endpoint metadata — the "where". Provider-specific fields are +/// carried as optional typed properties; unknown future fields land in +/// to survive round-trips without losing data. +/// +public sealed class Connection +{ + public string Id { get; set; } = string.Empty; + public ProviderKind Provider { get; set; } + public string? Description { get; set; } + + // Dataverse (the only provider implemented in v1 — Azure / ADO / Jira + // fields are intentionally not on this model until their providers land; + // ExtraFields below round-trips any unknown future keys without loss). + public string? EnvironmentUrl { get; set; } + public string? OrganizationId { get; set; } + public CloudInstance? Cloud { get; set; } + public string? TenantId { get; set; } + + /// Captured but unprocessed fields (forward-compat). + [JsonExtensionData] + public Dictionary? ExtraFields { get; set; } +} diff --git a/src/TALXIS.CLI.Core/Model/Credential.cs b/src/TALXIS.CLI.Core/Model/Credential.cs new file mode 100644 index 0000000..f5487a9 --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/Credential.cs @@ -0,0 +1,20 @@ +namespace TALXIS.CLI.Core.Model; + +/// +/// Authentication identity — the "who". Non-secret fields plus an optional +/// pointing at OS-vault-held material. +/// +public sealed class Credential +{ + public string Id { get; set; } = string.Empty; + public CredentialKind Kind { get; set; } + public string? Description { get; set; } + + public string? TenantId { get; set; } + public string? ApplicationId { get; set; } + public CloudInstance? Cloud { get; set; } + + public string? CertificatePath { get; set; } + + public SecretRef? SecretRef { get; set; } +} diff --git a/src/TALXIS.CLI.Core/Model/CredentialKind.cs b/src/TALXIS.CLI.Core/Model/CredentialKind.cs new file mode 100644 index 0000000..8e888e8 --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/CredentialKind.cs @@ -0,0 +1,17 @@ +namespace TALXIS.CLI.Core.Model; + +/// +/// Canonical credential kinds supported by txc v1. JSON serializes these as +/// kebab-case via . +/// +public enum CredentialKind +{ + InteractiveBrowser, + DeviceCode, + ClientSecret, + ClientCertificate, + ManagedIdentity, + WorkloadIdentityFederation, + AzureCli, + Pat, +} diff --git a/src/TALXIS.CLI.Core/Model/GlobalConfig.cs b/src/TALXIS.CLI.Core/Model/GlobalConfig.cs new file mode 100644 index 0000000..963cd76 --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/GlobalConfig.cs @@ -0,0 +1,23 @@ +namespace TALXIS.CLI.Core.Model; + +/// +/// Root of ${TXC_CONFIG_DIR:-~/.txc}/config.json. Holds the active-profile +/// pointer and tool-wide preferences. Kept intentionally narrow. +/// +public sealed class GlobalConfig +{ + public string? ActiveProfile { get; set; } + public LogSettings Log { get; set; } = new(); + public TelemetrySettings Telemetry { get; set; } = new(); +} + +public sealed class LogSettings +{ + public string Level { get; set; } = "information"; + public string Format { get; set; } = "plain"; +} + +public sealed class TelemetrySettings +{ + public bool Enabled { get; set; } = false; +} diff --git a/src/TALXIS.CLI.Core/Model/Profile.cs b/src/TALXIS.CLI.Core/Model/Profile.cs new file mode 100644 index 0000000..b6dff74 --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/Profile.cs @@ -0,0 +1,13 @@ +namespace TALXIS.CLI.Core.Model; + +/// +/// A named binding of one to one . +/// The only primitive users pass into leaf commands (via --profile). +/// +public sealed class Profile +{ + public string Id { get; set; } = string.Empty; + public string ConnectionRef { get; set; } = string.Empty; + public string CredentialRef { get; set; } = string.Empty; + public string? Description { get; set; } +} diff --git a/src/TALXIS.CLI.Core/Model/ProfileCollection.cs b/src/TALXIS.CLI.Core/Model/ProfileCollection.cs new file mode 100644 index 0000000..2f8f598 --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/ProfileCollection.cs @@ -0,0 +1,16 @@ +namespace TALXIS.CLI.Core.Model; + +public sealed class ProfileCollection +{ + public List Profiles { get; set; } = new(); +} + +public sealed class ConnectionCollection +{ + public List Connections { get; set; } = new(); +} + +public sealed class CredentialCollection +{ + public List Credentials { get; set; } = new(); +} diff --git a/src/TALXIS.CLI.Core/Model/ProviderKind.cs b/src/TALXIS.CLI.Core/Model/ProviderKind.cs new file mode 100644 index 0000000..8e796e0 --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/ProviderKind.cs @@ -0,0 +1,12 @@ +namespace TALXIS.CLI.Core.Model; + +/// +/// Canonical connection providers. JSON serializes as kebab-case. +/// +public enum ProviderKind +{ + Dataverse, + Azure, + Ado, + Jira, +} diff --git a/src/TALXIS.CLI.Core/Model/ResolvedProfileContext.cs b/src/TALXIS.CLI.Core/Model/ResolvedProfileContext.cs new file mode 100644 index 0000000..f4bec6b --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/ResolvedProfileContext.cs @@ -0,0 +1,26 @@ +namespace TALXIS.CLI.Core.Model; + +/// +/// Result of . +/// is null when the context was built ephemerally from +/// environment variables (no stored profile). +/// +public sealed record ResolvedProfileContext( + Profile? Profile, + Connection Connection, + Credential Credential, + ResolutionSource Source); + +public enum ResolutionSource +{ + /// Profile came from --profile flag. + CommandLine, + /// Profile came from TXC_PROFILE. + EnvironmentVariable, + /// Profile came from workspace .txc/workspace.json. + Workspace, + /// Profile came from global active pointer. + Global, + /// Ephemeral context built from env vars (no stored profile). + Ephemeral, +} diff --git a/src/TALXIS.CLI.Core/Model/SecretRef.cs b/src/TALXIS.CLI.Core/Model/SecretRef.cs new file mode 100644 index 0000000..7b8b300 --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/SecretRef.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace TALXIS.CLI.Core.Model; + +/// +/// Opaque handle to a secret held in the OS vault. Never contains the secret value itself. +/// Canonical string form: vault://com.talxis.txc/{credentialId}/{slot} +/// where slot is one of client-secret, pat, certificate-password. +/// +public sealed record SecretRef +{ + public const string Scheme = "vault"; + public const string Service = "com.talxis.txc"; + + public string CredentialId { get; init; } = string.Empty; + public string Slot { get; init; } = string.Empty; + + public static SecretRef Create(string credentialId, string slot) + => new() { CredentialId = credentialId, Slot = slot }; + + /// Full canonical URI form. + [JsonIgnore] + public string Uri => $"{Scheme}://{Service}/{CredentialId}/{Slot}"; + + public override string ToString() => Uri; + + public static SecretRef Parse(string value) + { + if (!TryParse(value, out var r)) + throw new FormatException($"Invalid SecretRef URI: '{value}'. Expected vault://{Service}//."); + return r!; + } + + public static bool TryParse(string? value, out SecretRef? result) + { + result = null; + if (string.IsNullOrWhiteSpace(value)) return false; + if (!System.Uri.TryCreate(value, UriKind.Absolute, out var uri)) return false; + if (!string.Equals(uri.Scheme, Scheme, StringComparison.OrdinalIgnoreCase)) return false; + if (!string.Equals(uri.Host, Service, StringComparison.OrdinalIgnoreCase)) return false; + var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length != 2) return false; + result = new SecretRef { CredentialId = segments[0], Slot = segments[1] }; + return true; + } +} diff --git a/src/TALXIS.CLI.Core/Model/WorkspaceConfig.cs b/src/TALXIS.CLI.Core/Model/WorkspaceConfig.cs new file mode 100644 index 0000000..40c8758 --- /dev/null +++ b/src/TALXIS.CLI.Core/Model/WorkspaceConfig.cs @@ -0,0 +1,10 @@ +namespace TALXIS.CLI.Core.Model; + +/// +/// Root of <repo>/.txc/workspace.json. v1 carries only a default +/// profile pointer; see plan.md §Storage layout. +/// +public sealed class WorkspaceConfig +{ + public string? DefaultProfile { get; set; } +} diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/DeploymentRelativeTimeParser.cs b/src/TALXIS.CLI.Core/Platforms/Dataverse/DeploymentRelativeTimeParser.cs similarity index 96% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/DeploymentRelativeTimeParser.cs rename to src/TALXIS.CLI.Core/Platforms/Dataverse/DeploymentRelativeTimeParser.cs index d97f3ba..8c3ad7e 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/DeploymentRelativeTimeParser.cs +++ b/src/TALXIS.CLI.Core/Platforms/Dataverse/DeploymentRelativeTimeParser.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Core.Platforms.Dataverse; /// /// Parses compact relative-time tokens used by txc environment deployment list --since. diff --git a/src/TALXIS.CLI.Core/Platforms/Dataverse/IDataPackageService.cs b/src/TALXIS.CLI.Core/Platforms/Dataverse/IDataPackageService.cs new file mode 100644 index 0000000..0fc1bd5 --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/Dataverse/IDataPackageService.cs @@ -0,0 +1,24 @@ +namespace TALXIS.CLI.Core.Platforms.Dataverse; + +/// +/// Outcome returned by . +/// +public sealed record DataPackageImportResult( + bool Succeeded, + string? ErrorMessage, + bool InteractiveAuthRequired); + +/// +/// Imports Configuration-Migration-Tool (CMT) data packages into the Dataverse +/// environment referenced by a profile. Hides subprocess plumbing and MSAL +/// exceptions from feature commands. +/// +public interface IDataPackageService +{ + Task ImportAsync( + string? profileName, + string dataPackagePath, + int connectionCount, + bool verbose, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Platforms/Dataverse/IDeploymentDetailService.cs b/src/TALXIS.CLI.Core/Platforms/Dataverse/IDeploymentDetailService.cs new file mode 100644 index 0000000..953b4ec --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/Dataverse/IDeploymentDetailService.cs @@ -0,0 +1,54 @@ +namespace TALXIS.CLI.Core.Platforms.Dataverse; + +/// +/// Discriminator for . +/// +public enum DeploymentRunKind +{ + Package, + Solution, + AsyncOperationInProgress, + AsyncOperationCompleted, +} + +/// +/// Status summary for a raw asyncoperation row when no correlated +/// solution-history record is available (yet). +/// +public sealed record AsyncOperationSummary( + Guid Id, + string StateLabel, + int StateCode, + int StatusCode, + bool Completed, + bool Succeeded, + string? Message); + +/// +/// Unified result for txc environment deployment show. Sub-fields are populated +/// according to ; unused slots are null / empty. +/// +public sealed record DeploymentDetailResult( + DeploymentRunKind Kind, + PackageHistoryRecord? Package, + IReadOnlyList CorrelatedSolutions, + SolutionHistoryRecord? Solution, + PackageHistoryRecord? ParentPackage, + Guid? ImportJobId, + string? FormattedImportLog, + AsyncOperationSummary? AsyncOperation, + IReadOnlyList Findings); + +/// +/// Provider-agnostic service for resolving a single deployment run (package or solution) +/// for the deployment show command. +/// +public interface IDeploymentDetailService +{ + Task GetByPackageRunIdAsync(string? profileName, Guid id, CancellationToken ct); + Task GetBySolutionRunIdAsync(string? profileName, Guid id, bool includeFull, CancellationToken ct); + Task GetByAsyncOperationIdAsync(string? profileName, Guid id, bool includeFull, CancellationToken ct); + Task GetLatestByPackageNameAsync(string? profileName, string packageName, CancellationToken ct); + Task GetLatestBySolutionNameAsync(string? profileName, string solutionName, bool includeFull, CancellationToken ct); + Task GetLatestAsync(string? profileName, bool includeFull, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Platforms/Dataverse/IDeploymentHistoryService.cs b/src/TALXIS.CLI.Core/Platforms/Dataverse/IDeploymentHistoryService.cs new file mode 100644 index 0000000..df64484 --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/Dataverse/IDeploymentHistoryService.cs @@ -0,0 +1,56 @@ +namespace TALXIS.CLI.Core.Platforms.Dataverse; + +/// +/// Row from the Dataverse packagehistory table (Package Deployer run records). +/// All values are UTC. +/// +public sealed record PackageHistoryRecord( + Guid Id, + string? Name, + string? Status, + string? Stage, + DateTime? StartedAtUtc, + DateTime? CompletedAtUtc, + Guid? OperationId, + string? Message, + Guid? CorrelationId = null); + +/// +/// Row from the Dataverse msdyn_solutionhistory table. Operation / +/// suboperation codes are already resolved to human-readable labels. +/// +public sealed record SolutionHistoryRecord( + Guid Id, + string? SolutionName, + string? SolutionVersion, + string? PackageName, + int? OperationCode, + string OperationLabel, + int? SuboperationCode, + string SuboperationLabel, + bool? OverwriteUnmanagedCustomizations, + DateTime? StartedAtUtc, + DateTime? CompletedAtUtc, + string? Result, + Guid? ActivityId = null); + +public sealed record DeploymentHistorySnapshot( + IReadOnlyList Packages, + IReadOnlyList Solutions); + +/// +/// Reads Package Deployer and solution-import history from the target +/// environment. Hides the two underlying readers and the connection lifetime +/// from feature commands. +/// +public interface IDeploymentHistoryService +{ + Task GetRecentAsync( + string? profileName, + bool includePackages, + bool includeSolutions, + int maxCount, + DateTime? sinceUtc, + bool problemsOnly, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Platforms/Dataverse/IPackageImportService.cs b/src/TALXIS.CLI.Core/Platforms/Dataverse/IPackageImportService.cs new file mode 100644 index 0000000..5ae026a --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/Dataverse/IPackageImportService.cs @@ -0,0 +1,42 @@ +namespace TALXIS.CLI.Core.Platforms.Dataverse; + +/// +/// Input for . +/// +public sealed record PackageImportRequest( + string? ProfileName, + string PackagePath, + string? Settings, + string? LogFile, + bool LogConsole, + bool Verbose, + string? NuGetPackageName, + string? NuGetPackageVersion, + /// + /// Temporary working directory created by NuGet restore. Service is responsible for + /// deleting it after the import attempt, regardless of outcome. + /// + string? TempWorkingDirectory); + +/// +/// Result of a single package import attempt. +/// +public sealed record PackageImportResult( + bool Succeeded, + string? ErrorMessage, + string? LogFilePath, + string? CmtLogFilePath, + /// + /// Set when the configured credential requires interactive reauthentication + /// (equivalent to an MSAL UI-required prompt). The command layer should translate this + /// into a user-facing hint, without taking a dependency on MSAL types. + /// + bool InteractiveAuthRequired); + +/// +/// Provider-agnostic service for running a deployable-package import into the target environment. +/// +public interface IPackageImportService +{ + Task ImportAsync(PackageImportRequest request, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Platforms/Dataverse/IPackageUninstallService.cs b/src/TALXIS.CLI.Core/Platforms/Dataverse/IPackageUninstallService.cs new file mode 100644 index 0000000..022b99a --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/Dataverse/IPackageUninstallService.cs @@ -0,0 +1,22 @@ +namespace TALXIS.CLI.Core.Platforms.Dataverse; + +public sealed record PackageUninstallRequest( + string? ProfileName, + string PackageSource, + string PackageVersion, + string? OutputDirectory); + +public sealed record PackageUninstallResult( + string PackageDisplayName, + IReadOnlyList UninstallOrder, + IReadOnlyList Outcomes); + +/// +/// Provider-agnostic service for uninstalling all solutions of a deployable package from the +/// target environment in reverse import order. Owns package-source reading, solution +/// orchestration, and history-record lifecycle. +/// +public interface IPackageUninstallService +{ + Task UninstallAsync(PackageUninstallRequest request, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Platforms/Dataverse/ISolutionImportService.cs b/src/TALXIS.CLI.Core/Platforms/Dataverse/ISolutionImportService.cs new file mode 100644 index 0000000..bb09913 --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/Dataverse/ISolutionImportService.cs @@ -0,0 +1,50 @@ +namespace TALXIS.CLI.Core.Platforms.Dataverse; + +/// +/// Information about a Dataverse solution's identity extracted from its ZIP +/// manifest or retrieved from the target environment. +/// +public sealed record SolutionInfo(string UniqueName, Version Version, bool Managed); + +public enum SolutionImportPath +{ + /// Target environment has no solution with this unique name. + Install, + /// Plain import over an existing solution (unmanaged, or managed without single-step upgrade). + Update, + /// Single-step upgrade (StageAndUpgradeRequest) over an existing managed solution. + Upgrade, +} + +public sealed record SolutionImportOptions( + bool StageAndUpgrade, + bool ForceOverwrite, + bool PublishWorkflows, + bool SkipDependencyCheck, + bool SkipLowerVersion, + bool Async); + +public sealed record SolutionImportResult( + SolutionImportPath Path, + SolutionInfo Source, + SolutionInfo? ExistingTarget, + Guid ImportJobId, + Guid? AsyncOperationId, + DateTime StartedAtUtc, + DateTime? CompletedAtUtc, + bool SmartDiffExpected, + string Status); + +/// +/// Imports Dataverse solution .zip files into the environment referenced by +/// a profile. Encapsulates source-manifest parsing, existing-solution lookup, +/// import-path planning, and the actual import request/polling. +/// +public interface ISolutionImportService +{ + Task ImportAsync( + string? profileName, + string solutionZipPath, + SolutionImportOptions options, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Platforms/Dataverse/ISolutionInventoryService.cs b/src/TALXIS.CLI.Core/Platforms/Dataverse/ISolutionInventoryService.cs new file mode 100644 index 0000000..8ab430c --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/Dataverse/ISolutionInventoryService.cs @@ -0,0 +1,30 @@ +namespace TALXIS.CLI.Core.Platforms.Dataverse; + +/// +/// Installed-solution row returned by . +/// Lives in Core so feature commands can bind to it without referencing the +/// Dataverse platform implementation project. +/// +public sealed record InstalledSolutionRecord( + Guid Id, + string UniqueName, + string? FriendlyName, + string? Version, + bool Managed); + +/// +/// Dataverse solution inventory operations. Implemented by the Dataverse +/// platform adapter; consumed by thin feature commands. +/// +public interface ISolutionInventoryService +{ + /// + /// Lists installed solutions in the environment referenced by + /// . Handles profile resolution and + /// connection lifetime internally. + /// + Task> ListAsync( + string? profileName, + bool? managedFilter, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Core/Platforms/Dataverse/ISolutionUninstallService.cs b/src/TALXIS.CLI.Core/Platforms/Dataverse/ISolutionUninstallService.cs new file mode 100644 index 0000000..54a29a7 --- /dev/null +++ b/src/TALXIS.CLI.Core/Platforms/Dataverse/ISolutionUninstallService.cs @@ -0,0 +1,27 @@ +namespace TALXIS.CLI.Core.Platforms.Dataverse; + +public enum SolutionUninstallStatus +{ + Success, + NotFound, + Ambiguous, + Failed, +} + +public sealed record SolutionUninstallOutcome( + string SolutionName, + Guid? SolutionId, + SolutionUninstallStatus Status, + string Message); + +/// +/// Uninstalls a Dataverse solution by unique name. Abstracts profile +/// resolution and connection lifetime so feature commands remain thin. +/// +public interface ISolutionUninstallService +{ + Task UninstallByUniqueNameAsync( + string? profileName, + string uniqueName, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/NuGetPackageInstallOptions.cs b/src/TALXIS.CLI.Core/Platforms/Packaging/NuGetPackageInstallOptions.cs similarity index 70% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/NuGetPackageInstallOptions.cs rename to src/TALXIS.CLI.Core/Platforms/Packaging/NuGetPackageInstallOptions.cs index 4e96cc4..17c8908 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/NuGetPackageInstallOptions.cs +++ b/src/TALXIS.CLI.Core/Platforms/Packaging/NuGetPackageInstallOptions.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Core.Platforms.Packaging; public sealed record NuGetPackageInstallOptions( string PackageName, diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/NuGetPackageInstallResult.cs b/src/TALXIS.CLI.Core/Platforms/Packaging/NuGetPackageInstallResult.cs similarity index 83% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/NuGetPackageInstallResult.cs rename to src/TALXIS.CLI.Core/Platforms/Packaging/NuGetPackageInstallResult.cs index 30f53ec..215b7fb 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/NuGetPackageInstallResult.cs +++ b/src/TALXIS.CLI.Core/Platforms/Packaging/NuGetPackageInstallResult.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Core.Platforms.Packaging; public sealed record NuGetPackageInstallResult( string PackageName, diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/NuGetPackageInstallerService.cs b/src/TALXIS.CLI.Core/Platforms/Packaging/NuGetPackageInstallerService.cs similarity index 99% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/NuGetPackageInstallerService.cs rename to src/TALXIS.CLI.Core/Platforms/Packaging/NuGetPackageInstallerService.cs index d028c2b..0f4b85b 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/NuGetPackageInstallerService.cs +++ b/src/TALXIS.CLI.Core/Platforms/Packaging/NuGetPackageInstallerService.cs @@ -2,7 +2,7 @@ using System.Net.Http.Json; using System.Text.Json.Serialization; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Core.Platforms.Packaging; public sealed class NuGetPackageInstallerService { diff --git a/src/TALXIS.CLI.Core/Resolution/ConfigurationResolver.cs b/src/TALXIS.CLI.Core/Resolution/ConfigurationResolver.cs new file mode 100644 index 0000000..d18c09f --- /dev/null +++ b/src/TALXIS.CLI.Core/Resolution/ConfigurationResolver.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Resolution; + +/// +/// Default implementation of . +/// Resolves the profile name using the 5-layer precedence documented in plan.md: +/// command-line > TXC_PROFILE > workspace file > global active pointer +/// > ephemeral (future: built from env vars by provider-specific builders). +/// Once a profile name is picked, the linked Connection and Credential are +/// loaded from the file-backed stores. Vault-held secret values are fetched +/// later, on demand, by providers. +/// +public sealed class ConfigurationResolver : IConfigurationResolver +{ + public const string ProfileEnvVar = "TXC_PROFILE"; + + private readonly IProfileStore _profiles; + private readonly IConnectionStore _connections; + private readonly ICredentialStore _credentials; + private readonly IGlobalConfigStore _globalConfig; + private readonly IWorkspaceDiscovery _workspace; + private readonly IEnvironmentReader _env; + private readonly ILogger _log; + + public ConfigurationResolver( + IProfileStore profiles, + IConnectionStore connections, + ICredentialStore credentials, + IGlobalConfigStore globalConfig, + IWorkspaceDiscovery workspace, + IEnvironmentReader? env = null, + ILogger? log = null) + { + _profiles = profiles; + _connections = connections; + _credentials = credentials; + _globalConfig = globalConfig; + _workspace = workspace; + _env = env ?? ProcessEnvironmentReader.Instance; + _log = log ?? NullLogger.Instance; + } + + public async Task ResolveAsync(string? profileName, CancellationToken ct) + { + var (name, source) = await PickProfileNameAsync(profileName, ct).ConfigureAwait(false); + if (name is null) + { + throw new ConfigurationResolutionException( + "No txc profile could be resolved. Pass --profile , set TXC_PROFILE, " + + "pin a workspace default with 'txc config profile pin', or select a global default with 'txc config profile select'."); + } + + var profile = await _profiles.GetAsync(name, ct).ConfigureAwait(false) + ?? throw new ConfigurationResolutionException( + $"Profile '{name}' (source: {source}) was not found. Run 'txc config profile list' to see available profiles."); + + var connection = await _connections.GetAsync(profile.ConnectionRef, ct).ConfigureAwait(false) + ?? throw new ConfigurationResolutionException( + $"Profile '{profile.Id}' references missing connection '{profile.ConnectionRef}'."); + + var credential = await _credentials.GetAsync(profile.CredentialRef, ct).ConfigureAwait(false) + ?? throw new ConfigurationResolutionException( + $"Profile '{profile.Id}' references missing credential '{profile.CredentialRef}'."); + + _log.LogDebug("Resolved profile '{ProfileId}' (connection '{ConnectionId}', credential '{CredentialId}') from {Source}.", + profile.Id, connection.Id, credential.Id, source); + + return new ResolvedProfileContext(profile, connection, credential, source); + } + + private async Task<(string? name, ResolutionSource source)> PickProfileNameAsync(string? commandLineProfile, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(commandLineProfile)) + return (commandLineProfile, ResolutionSource.CommandLine); + + var envProfile = _env.Get(ProfileEnvVar); + if (!string.IsNullOrWhiteSpace(envProfile)) + return (envProfile, ResolutionSource.EnvironmentVariable); + + var cwd = _env.GetCurrentDirectory(); + var workspace = await _workspace.DiscoverAsync(cwd, ct).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(workspace?.Config.DefaultProfile)) + return (workspace!.Config.DefaultProfile, ResolutionSource.Workspace); + + var global = await _globalConfig.LoadAsync(ct).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(global.ActiveProfile)) + return (global.ActiveProfile, ResolutionSource.Global); + + return (null, ResolutionSource.Ephemeral); + } +} diff --git a/src/TALXIS.CLI.Core/Resolution/IEnvironmentReader.cs b/src/TALXIS.CLI.Core/Resolution/IEnvironmentReader.cs new file mode 100644 index 0000000..819c4ce --- /dev/null +++ b/src/TALXIS.CLI.Core/Resolution/IEnvironmentReader.cs @@ -0,0 +1,18 @@ +namespace TALXIS.CLI.Core.Resolution; + +/// +/// Thin abstraction over so tests can inject +/// a deterministic environment without touching global process state. +/// +public interface IEnvironmentReader +{ + string? Get(string name); + string GetCurrentDirectory(); +} + +public sealed class ProcessEnvironmentReader : IEnvironmentReader +{ + public static readonly ProcessEnvironmentReader Instance = new(); + public string? Get(string name) => Environment.GetEnvironmentVariable(name); + public string GetCurrentDirectory() => Directory.GetCurrentDirectory(); +} diff --git a/src/TALXIS.CLI.Core/Resolution/WorkspaceDiscovery.cs b/src/TALXIS.CLI.Core/Resolution/WorkspaceDiscovery.cs new file mode 100644 index 0000000..4f2a589 --- /dev/null +++ b/src/TALXIS.CLI.Core/Resolution/WorkspaceDiscovery.cs @@ -0,0 +1,34 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Core.Resolution; + +/// +/// Walks from startDirectory up to the filesystem root looking for +/// .txc/workspace.json. First hit wins. +/// +public sealed class WorkspaceDiscovery : IWorkspaceDiscovery +{ + public const string DirectoryName = ".txc"; + public const string FileName = "workspace.json"; + + public async Task DiscoverAsync(string startDirectory, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(startDirectory)) return null; + + var dir = new DirectoryInfo(Path.GetFullPath(startDirectory)); + while (dir is not null) + { + ct.ThrowIfCancellationRequested(); + var candidate = Path.Combine(dir.FullName, DirectoryName, FileName); + if (File.Exists(candidate)) + { + var config = await JsonFile.ReadOrDefaultAsync(candidate, ct).ConfigureAwait(false); + return new WorkspaceResolution(dir.FullName, candidate, config); + } + dir = dir.Parent; + } + return null; + } +} diff --git a/src/TALXIS.CLI.Core/Shared/McpIgnoreAttribute.cs b/src/TALXIS.CLI.Core/Shared/McpIgnoreAttribute.cs new file mode 100644 index 0000000..4d39165 --- /dev/null +++ b/src/TALXIS.CLI.Core/Shared/McpIgnoreAttribute.cs @@ -0,0 +1,12 @@ +namespace TALXIS.CLI.Core; + +/// +/// Marks a -decorated class as excluded from the MCP tool registry. +/// The command (and every descendant in its sub-tree) is skipped when the MCP server enumerates tools. +/// +/// Apply to commands that are not meaningful in a headless/MCP agent context: +/// interactive flows, long-running server processes, or local-only maintenance operations. +/// +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class McpIgnoreAttribute : Attribute { } diff --git a/src/TALXIS.CLI.Shared/OutputWriter.cs b/src/TALXIS.CLI.Core/Shared/OutputWriter.cs similarity index 98% rename from src/TALXIS.CLI.Shared/OutputWriter.cs rename to src/TALXIS.CLI.Core/Shared/OutputWriter.cs index f645fe8..e10451f 100644 --- a/src/TALXIS.CLI.Shared/OutputWriter.cs +++ b/src/TALXIS.CLI.Core/Shared/OutputWriter.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Shared; +namespace TALXIS.CLI.Core; /// /// Writes command result data to stdout. Use this for output that IS the tool result diff --git a/src/TALXIS.CLI.Core/Storage/ConfigPaths.cs b/src/TALXIS.CLI.Core/Storage/ConfigPaths.cs new file mode 100644 index 0000000..f38a1cf --- /dev/null +++ b/src/TALXIS.CLI.Core/Storage/ConfigPaths.cs @@ -0,0 +1,43 @@ +namespace TALXIS.CLI.Core.Storage; + +/// +/// Resolves the effective configuration directory for this process. +/// Precedence: TXC_CONFIG_DIR environment variable, else +/// $HOME/.txc (%USERPROFILE%\.txc on Windows). +/// Paths are created lazily by stores when they first write. +/// +public sealed class ConfigPaths +{ + public const string EnvVar = "TXC_CONFIG_DIR"; + + public string Root { get; } + + public string GlobalConfigFile => Path.Combine(Root, "config.json"); + public string ProfilesFile => Path.Combine(Root, "profiles.json"); + public string ConnectionsFile => Path.Combine(Root, "connections.json"); + public string CredentialsFile => Path.Combine(Root, "credentials.json"); + public string AuthDirectory => Path.Combine(Root, "auth"); + + public ConfigPaths(string root) + { + if (string.IsNullOrWhiteSpace(root)) + throw new ArgumentException("Config root must not be empty.", nameof(root)); + Root = Path.GetFullPath(root); + } + + public static ConfigPaths FromEnvironment(IDictionary? envOverride = null) + { + string? explicitDir = envOverride is null + ? Environment.GetEnvironmentVariable(EnvVar) + : (envOverride.TryGetValue(EnvVar, out var v) ? v : null); + + if (!string.IsNullOrWhiteSpace(explicitDir)) + return new ConfigPaths(explicitDir); + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + throw new InvalidOperationException("Unable to determine user profile directory for default TXC config path."); + + return new ConfigPaths(Path.Combine(home, ".txc")); + } +} diff --git a/src/TALXIS.CLI.Core/Storage/ConnectionStore.cs b/src/TALXIS.CLI.Core/Storage/ConnectionStore.cs new file mode 100644 index 0000000..9868cbc --- /dev/null +++ b/src/TALXIS.CLI.Core/Storage/ConnectionStore.cs @@ -0,0 +1,54 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Storage; + +public sealed class ConnectionStore : IConnectionStore +{ + private readonly string _path; + private readonly SemaphoreSlim _lock = new(1, 1); + + public ConnectionStore(ConfigPaths paths) { _path = paths.ConnectionsFile; } + + public async Task> ListAsync(CancellationToken ct) + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + return collection.Connections; + } + + public async Task GetAsync(string id, CancellationToken ct) + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + return collection.Connections.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + public async Task UpsertAsync(Connection connection, CancellationToken ct) + { + if (connection is null) throw new ArgumentNullException(nameof(connection)); + if (string.IsNullOrWhiteSpace(connection.Id)) throw new ArgumentException("Connection.Id is required.", nameof(connection)); + + await _lock.WaitAsync(ct).ConfigureAwait(false); + try + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + collection.Connections.RemoveAll(c => string.Equals(c.Id, connection.Id, StringComparison.OrdinalIgnoreCase)); + collection.Connections.Add(connection); + await JsonFile.WriteAtomicAsync(_path, collection, ct).ConfigureAwait(false); + } + finally { _lock.Release(); } + } + + public async Task DeleteAsync(string id, CancellationToken ct) + { + await _lock.WaitAsync(ct).ConfigureAwait(false); + try + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + var removed = collection.Connections.RemoveAll(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase)); + if (removed == 0) return false; + await JsonFile.WriteAtomicAsync(_path, collection, ct).ConfigureAwait(false); + return true; + } + finally { _lock.Release(); } + } +} diff --git a/src/TALXIS.CLI.Core/Storage/CredentialStore.cs b/src/TALXIS.CLI.Core/Storage/CredentialStore.cs new file mode 100644 index 0000000..2e96cf7 --- /dev/null +++ b/src/TALXIS.CLI.Core/Storage/CredentialStore.cs @@ -0,0 +1,54 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Storage; + +public sealed class CredentialStore : ICredentialStore +{ + private readonly string _path; + private readonly SemaphoreSlim _lock = new(1, 1); + + public CredentialStore(ConfigPaths paths) { _path = paths.CredentialsFile; } + + public async Task> ListAsync(CancellationToken ct) + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + return collection.Credentials; + } + + public async Task GetAsync(string id, CancellationToken ct) + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + return collection.Credentials.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + public async Task UpsertAsync(Credential credential, CancellationToken ct) + { + if (credential is null) throw new ArgumentNullException(nameof(credential)); + if (string.IsNullOrWhiteSpace(credential.Id)) throw new ArgumentException("Credential.Id is required.", nameof(credential)); + + await _lock.WaitAsync(ct).ConfigureAwait(false); + try + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + collection.Credentials.RemoveAll(c => string.Equals(c.Id, credential.Id, StringComparison.OrdinalIgnoreCase)); + collection.Credentials.Add(credential); + await JsonFile.WriteAtomicAsync(_path, collection, ct).ConfigureAwait(false); + } + finally { _lock.Release(); } + } + + public async Task DeleteAsync(string id, CancellationToken ct) + { + await _lock.WaitAsync(ct).ConfigureAwait(false); + try + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + var removed = collection.Credentials.RemoveAll(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase)); + if (removed == 0) return false; + await JsonFile.WriteAtomicAsync(_path, collection, ct).ConfigureAwait(false); + return true; + } + finally { _lock.Release(); } + } +} diff --git a/src/TALXIS.CLI.Core/Storage/GlobalConfigStore.cs b/src/TALXIS.CLI.Core/Storage/GlobalConfigStore.cs new file mode 100644 index 0000000..c2a7a84 --- /dev/null +++ b/src/TALXIS.CLI.Core/Storage/GlobalConfigStore.cs @@ -0,0 +1,23 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Storage; + +public sealed class GlobalConfigStore : IGlobalConfigStore +{ + private readonly string _path; + private readonly SemaphoreSlim _lock = new(1, 1); + + public GlobalConfigStore(ConfigPaths paths) { _path = paths.GlobalConfigFile; } + + public Task LoadAsync(CancellationToken ct) + => JsonFile.ReadOrDefaultAsync(_path, ct); + + public async Task SaveAsync(GlobalConfig config, CancellationToken ct) + { + if (config is null) throw new ArgumentNullException(nameof(config)); + await _lock.WaitAsync(ct).ConfigureAwait(false); + try { await JsonFile.WriteAtomicAsync(_path, config, ct).ConfigureAwait(false); } + finally { _lock.Release(); } + } +} diff --git a/src/TALXIS.CLI.Core/Storage/JsonFile.cs b/src/TALXIS.CLI.Core/Storage/JsonFile.cs new file mode 100644 index 0000000..f124994 --- /dev/null +++ b/src/TALXIS.CLI.Core/Storage/JsonFile.cs @@ -0,0 +1,43 @@ +using System.Text.Json; + +namespace TALXIS.CLI.Core.Storage; + +/// +/// Shared helpers for atomic JSON read/write used by every file-backed store. +/// Writes go via a sibling *.tmp file and +/// to avoid torn files on crash. +/// +public static class JsonFile +{ + public static async Task ReadOrDefaultAsync(string path, CancellationToken ct) where T : new() + { + if (!File.Exists(path)) return new T(); + await using var stream = File.OpenRead(path); + var value = await JsonSerializer.DeserializeAsync(stream, TxcJsonOptions.Default, ct).ConfigureAwait(false); + return value ?? new T(); + } + + public static async Task WriteAtomicAsync(string path, T value, CancellationToken ct) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + var tempPath = path + ".tmp"; + await using (var stream = File.Create(tempPath)) + { + await JsonSerializer.SerializeAsync(stream, value, TxcJsonOptions.Default, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + } + + if (File.Exists(path)) + { + // File.Replace preserves the destination file's attributes/ACLs. + File.Replace(tempPath, path, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + else + { + File.Move(tempPath, path); + } + } +} diff --git a/src/TALXIS.CLI.Core/Storage/ProfileStore.cs b/src/TALXIS.CLI.Core/Storage/ProfileStore.cs new file mode 100644 index 0000000..b50d9e0 --- /dev/null +++ b/src/TALXIS.CLI.Core/Storage/ProfileStore.cs @@ -0,0 +1,54 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Storage; + +public sealed class ProfileStore : IProfileStore +{ + private readonly string _path; + private readonly SemaphoreSlim _lock = new(1, 1); + + public ProfileStore(ConfigPaths paths) { _path = paths.ProfilesFile; } + + public async Task> ListAsync(CancellationToken ct) + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + return collection.Profiles; + } + + public async Task GetAsync(string id, CancellationToken ct) + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + return collection.Profiles.FirstOrDefault(p => string.Equals(p.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + public async Task UpsertAsync(Profile profile, CancellationToken ct) + { + if (profile is null) throw new ArgumentNullException(nameof(profile)); + if (string.IsNullOrWhiteSpace(profile.Id)) throw new ArgumentException("Profile.Id is required.", nameof(profile)); + + await _lock.WaitAsync(ct).ConfigureAwait(false); + try + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + collection.Profiles.RemoveAll(p => string.Equals(p.Id, profile.Id, StringComparison.OrdinalIgnoreCase)); + collection.Profiles.Add(profile); + await JsonFile.WriteAtomicAsync(_path, collection, ct).ConfigureAwait(false); + } + finally { _lock.Release(); } + } + + public async Task DeleteAsync(string id, CancellationToken ct) + { + await _lock.WaitAsync(ct).ConfigureAwait(false); + try + { + var collection = await JsonFile.ReadOrDefaultAsync(_path, ct).ConfigureAwait(false); + var removed = collection.Profiles.RemoveAll(p => string.Equals(p.Id, id, StringComparison.OrdinalIgnoreCase)); + if (removed == 0) return false; + await JsonFile.WriteAtomicAsync(_path, collection, ct).ConfigureAwait(false); + return true; + } + finally { _lock.Release(); } + } +} diff --git a/src/TALXIS.CLI.Core/Storage/SecretRefJsonConverter.cs b/src/TALXIS.CLI.Core/Storage/SecretRefJsonConverter.cs new file mode 100644 index 0000000..487d653 --- /dev/null +++ b/src/TALXIS.CLI.Core/Storage/SecretRefJsonConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Core.Storage; + +/// +/// Serializes as its canonical URI string form. +/// +internal sealed class SecretRefJsonConverter : JsonConverter +{ + public override SecretRef? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return null; + var raw = reader.GetString(); + if (string.IsNullOrWhiteSpace(raw)) return null; + return SecretRef.Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, SecretRef value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Uri); +} diff --git a/src/TALXIS.CLI.Core/Storage/TxcJsonOptions.cs b/src/TALXIS.CLI.Core/Storage/TxcJsonOptions.cs new file mode 100644 index 0000000..d00a3d8 --- /dev/null +++ b/src/TALXIS.CLI.Core/Storage/TxcJsonOptions.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TALXIS.CLI.Core.Storage; + +/// +/// Single source of truth for JSON serialization across txc config files: +/// camelCase property naming, kebab-case string enums, ignore unknown fields +/// on read, indented write, preserve extension-data round-trips. +/// +public static class TxcJsonOptions +{ + public static readonly JsonSerializerOptions Default = BuildOptions(); + + private static JsonSerializerOptions BuildOptions() + { + var opts = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + opts.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower)); + opts.Converters.Add(new SecretRefJsonConverter()); + return opts; + } +} diff --git a/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj b/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj new file mode 100644 index 0000000..c46dd37 --- /dev/null +++ b/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + TALXIS.CLI.Core + + + + + + + + + + + + + + + diff --git a/src/TALXIS.CLI.Core/Vault/MsalBackedCredentialVault.cs b/src/TALXIS.CLI.Core/Vault/MsalBackedCredentialVault.cs new file mode 100644 index 0000000..03957d3 --- /dev/null +++ b/src/TALXIS.CLI.Core/Vault/MsalBackedCredentialVault.cs @@ -0,0 +1,186 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Client.Extensions.Msal; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Core.Vault; + +/// +/// backed by . Stores +/// the full vault as a single JSON dictionary blob keyed by +/// "{credentialId}::{slot}". Per-OS encryption is delegated to MSAL +/// Extensions (DPAPI on Windows, Keychain on macOS, libsecret on Linux); we +/// never call those APIs directly. +/// +/// +/// Concurrency: MSAL Extensions' CrossPlatLock handles cross-process +/// serialization via a .lockfile next to the cache. A single +/// handles in-process callers. Do not layer more +/// locks — pac CLI layered three and it slowed things down with no gain. +/// +public sealed class MsalBackedCredentialVault : ICredentialVault +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = false, + }; + + private readonly MsalCacheHelper _helper; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + + /// Diagnostic hook: the underlying cache helper. Used by tests to + /// assert that the helper is instantiated once per vault per process. + internal MsalCacheHelper CacheHelper => _helper; + + /// Diagnostic hook: whether plaintext storage was selected. + internal bool UsesPlaintextFallback { get; } + + private MsalBackedCredentialVault( + MsalCacheHelper helper, + ILogger logger, + bool usesPlaintextFallback) + { + _helper = helper; + _logger = logger; + UsesPlaintextFallback = usesPlaintextFallback; + } + + /// + /// Factory used by DI. Builds the generic secret vault + /// (txc.secrets.v1.dat) only; the MSAL token cache comes online in + /// milestone 4. + /// + public static async Task CreateAsync( + ConfigPaths paths, + IEnvironmentReader env, + ILogger logger, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(env); + logger ??= NullLogger.Instance; + + var options = VaultOptions.Secrets(env); + var helper = await MsalCacheHelperFactory.CreateAsync(options, paths, logger, ct).ConfigureAwait(false); + return new MsalBackedCredentialVault(helper, logger, options.UsePlaintextFallback); + } + + /// + /// Test-only factory: builds a vault around a caller-provided + /// . Enables deterministic tests that force the + /// plaintext-file path without mutating process env vars. + /// + internal static async Task CreateForTestingAsync( + VaultOptions options, + ConfigPaths paths, + ILogger? logger = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(paths); + logger ??= NullLogger.Instance; + var helper = await MsalCacheHelperFactory.CreateAsync(options, paths, logger, ct).ConfigureAwait(false); + return new MsalBackedCredentialVault(helper, logger, options.UsePlaintextFallback); + } + + public async Task GetSecretAsync(SecretRef reference, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(reference); + var key = MakeKey(reference); + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var map = LoadBlob(); + return map.TryGetValue(key, out var value) ? value : null; + } + finally { _gate.Release(); } + } + + public async Task SetSecretAsync(SecretRef reference, string value, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(value); + var key = MakeKey(reference); + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var map = LoadBlob(); + map[key] = value; + SaveBlob(map); + } + finally { _gate.Release(); } + } + + public async Task DeleteSecretAsync(SecretRef reference, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(reference); + var key = MakeKey(reference); + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var map = LoadBlob(); + if (!map.Remove(key)) + return false; + SaveBlob(map); + return true; + } + finally { _gate.Release(); } + } + + private Dictionary LoadBlob() + { + byte[] raw; + try + { + raw = _helper.LoadUnencryptedTokenCache(); + } + catch (Exception ex) + { + throw new VaultUnavailableException(ex); + } + + if (raw is null || raw.Length == 0) + return new Dictionary(StringComparer.Ordinal); + + try + { + var parsed = JsonSerializer.Deserialize>(raw, SerializerOptions); + return parsed is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(parsed, StringComparer.Ordinal); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Vault blob failed to parse as JSON dictionary; treating as empty."); + return new Dictionary(StringComparer.Ordinal); + } + } + + private void SaveBlob(Dictionary map) + { + var json = JsonSerializer.SerializeToUtf8Bytes(map, SerializerOptions); + try + { + _helper.SaveUnencryptedTokenCache(json); + } + catch (Exception ex) + { + throw new VaultUnavailableException(ex); + } + } + + private static string MakeKey(SecretRef r) + { + if (string.IsNullOrWhiteSpace(r.CredentialId)) + throw new ArgumentException("SecretRef.CredentialId must not be empty.", nameof(r)); + if (string.IsNullOrWhiteSpace(r.Slot)) + throw new ArgumentException("SecretRef.Slot must not be empty.", nameof(r)); + return $"{r.CredentialId}::{r.Slot}"; + } +} diff --git a/src/TALXIS.CLI.Core/Vault/MsalCacheHelperFactory.cs b/src/TALXIS.CLI.Core/Vault/MsalCacheHelperFactory.cs new file mode 100644 index 0000000..d07ba8d --- /dev/null +++ b/src/TALXIS.CLI.Core/Vault/MsalCacheHelperFactory.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Client.Extensions.Msal; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Core.Vault; + +/// +/// Builds instances for a given , +/// handling OS-vault configuration and plaintext-fallback routing. All per-OS +/// interop (DPAPI, Keychain, libsecret) is delegated to MSAL Extensions — we +/// never touch the Security framework, D-Bus, or ProtectedData directly. +/// +internal static class MsalCacheHelperFactory +{ + /// + /// Creates and verifies an . On + /// : + /// + /// Windows → hard error (DPAPI should always be available). + /// Linux with TXC_PLAINTEXT_FALLBACK=1 or .UsePlaintextFallback → plaintext file fallback with warning. + /// Otherwise → throw . + /// + /// + public static async Task CreateAsync( + VaultOptions options, + ConfigPaths paths, + ILogger logger, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(logger); + + Directory.CreateDirectory(paths.AuthDirectory); + + if (options.UsePlaintextFallback) + return await CreatePlaintextAsync(options, paths, logger).ConfigureAwait(false); + + var props = BuildProtectedProperties(options, paths); + var helper = await MsalCacheHelper.CreateAsync(props).ConfigureAwait(false); + try + { + helper.VerifyPersistence(); + return helper; + } + catch (MsalCachePersistenceException ex) + { + if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) + throw new VaultUnavailableException(ex); + + logger.LogWarning(ex, + "OS credential vault (libsecret) is unavailable; no plaintext opt-in set. " + + "Set {EnvVar}=1 to use a plaintext file fallback at chmod 600.", + VaultOptions.LinuxPlaintextEnvVar); + throw new VaultUnavailableException(ex); + } + } + + private static async Task CreatePlaintextAsync( + VaultOptions options, + ConfigPaths paths, + ILogger logger) + { + var fallbackPath = Path.Combine(paths.AuthDirectory, options.FallbackCacheFileName); + logger.LogWarning( + "Vault using PLAINTEXT file-based storage at {Path} (opt-in: {Reason}). " + + "Secrets are NOT protected by the OS; rely on POSIX file permissions (chmod 600) only.", + fallbackPath, options.PlaintextReason ?? "explicit"); + + var props = new StorageCreationPropertiesBuilder(options.FallbackCacheFileName, paths.AuthDirectory) + .WithUnprotectedFile() + .Build(); + + var helper = await MsalCacheHelper.CreateAsync(props).ConfigureAwait(false); + TrySetOwnerOnlyPermissions(fallbackPath, logger); + return helper; + } + + private static StorageCreationProperties BuildProtectedProperties(VaultOptions options, ConfigPaths paths) + { + var builder = new StorageCreationPropertiesBuilder(options.CacheFileName, paths.AuthDirectory) + .WithMacKeyChain(VaultOptions.KeychainService, options.KeychainAccount) + .WithLinuxKeyring( + schemaName: VaultOptions.KeychainService, + collection: MsalCacheHelper.LinuxKeyRingDefaultCollection, + secretLabel: options.LinuxKeyringLabel, + attribute1: new KeyValuePair("Version", "1"), + attribute2: new KeyValuePair("CacheKind", options.LinuxCacheKind)); + return builder.Build(); + } + + private static void TrySetOwnerOnlyPermissions(string path, ILogger logger) + { + if (OperatingSystem.IsWindows() || !File.Exists(path)) + return; + + try + { + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to chmod 600 plaintext vault at {Path}.", path); + } + } +} diff --git a/src/TALXIS.CLI.Core/Vault/VaultOptions.cs b/src/TALXIS.CLI.Core/Vault/VaultOptions.cs new file mode 100644 index 0000000..b22803b --- /dev/null +++ b/src/TALXIS.CLI.Core/Vault/VaultOptions.cs @@ -0,0 +1,114 @@ +using TALXIS.CLI.Core.Internal; +using TALXIS.CLI.Core.Resolution; + +namespace TALXIS.CLI.Core.Vault; + +/// +/// Per-cache-file vault configuration: cache filename, OS-vault identity, and +/// plaintext-fallback / file-based toggles. Two named factories exist so the +/// generic secret vault and the MSAL token cache stay independent (different +/// blast radius, different lifetimes, independent plaintext-fallback consent), +/// matching the pac CLI layout. +/// +public sealed record VaultOptions +{ + /// Keychain service name (also Linux keyring schema name). + public const string KeychainService = "com.talxis.txc"; + + /// Cache file name, relative to . + public required string CacheFileName { get; init; } + + /// macOS Keychain account; also used as Linux keyring attribute. + public required string KeychainAccount { get; init; } + + /// Linux keyring label shown in Seahorse / gnome-keyring UIs. + public required string LinuxKeyringLabel { get; init; } + + /// Linux keyring CacheKind attribute value. + public required string LinuxCacheKind { get; init; } + + /// + /// When true, the OS vault is bypassed and the cache is stored unencrypted + /// to . Chosen explicitly via env var or + /// flag, not as an automatic fallback. Triggers an ILogger warning on every + /// read and write. + /// + public bool UsePlaintextFallback { get; init; } + + /// Human-readable reason plaintext mode was selected (env var name, flag, etc.). + public string? PlaintextReason { get; init; } + + /// + /// Fallback filename used when is true. + /// Distinct from so ls ~/.txc/auth/ + /// makes the unprotected state visible at a glance. + /// + public string FallbackCacheFileName => + CacheFileName.EndsWith(".dat", StringComparison.Ordinal) + ? CacheFileName[..^".dat".Length] + ".fallback.dat" + : CacheFileName + ".fallback.dat"; + + /// + /// Options for the generic secret vault (txc.secrets.v1.dat): holds + /// client-secret / PAT / certificate-password blobs keyed by + /// "{credentialId}::{slot}". + /// + public static VaultOptions Secrets(IEnvironmentReader env) + { + ArgumentNullException.ThrowIfNull(env); + var (plaintext, reason) = ResolvePlaintextOptIn(env); + return new VaultOptions + { + CacheFileName = "txc.secrets.v1.dat", + KeychainAccount = "secrets", + LinuxKeyringLabel = "TALXIS CLI secrets", + LinuxCacheKind = "TXC_Secret_Vault", + UsePlaintextFallback = plaintext, + PlaintextReason = reason, + }; + } + + /// + /// Options for the MSAL token cache (txc.msal.tokens.v1.dat). + /// Reserved here so naming is locked before the Dataverse provider lands + /// in milestone 4; not yet wired into DI. + /// + public static VaultOptions MsalTokenCache(IEnvironmentReader env) + { + ArgumentNullException.ThrowIfNull(env); + var (plaintext, reason) = ResolvePlaintextOptIn(env); + return new VaultOptions + { + CacheFileName = "txc.msal.tokens.v1.dat", + KeychainAccount = "msal-tokens", + LinuxKeyringLabel = "TALXIS CLI MSAL token cache", + LinuxCacheKind = "TXC_Msal_Token_Cache", + UsePlaintextFallback = plaintext, + PlaintextReason = reason, + }; + } + + /// + /// Env var names honored to pick plaintext / file-based storage. Intentionally + /// public so the (future) `--plaintext-fallback` flag can set the same value. + /// + public const string LinuxPlaintextEnvVar = "TXC_PLAINTEXT_FALLBACK"; + public const string MacFileModeEnvVar = "TXC_TOKEN_CACHE_MODE"; + + private static (bool plaintext, string? reason) ResolvePlaintextOptIn(IEnvironmentReader env) + { + if (OperatingSystem.IsLinux()) + { + var v = env.Get(LinuxPlaintextEnvVar); + if (EnvBool.IsTruthy(v)) + return (true, $"{LinuxPlaintextEnvVar}={v}"); + } + else if (OperatingSystem.IsMacOS()) + { + var v = env.Get(MacFileModeEnvVar); + if (!string.IsNullOrEmpty(v) && string.Equals(v, "file", StringComparison.OrdinalIgnoreCase)) + return (true, $"{MacFileModeEnvVar}=file"); + } + return (false, null); + } +} diff --git a/src/TALXIS.CLI.Core/Vault/VaultUnavailableException.cs b/src/TALXIS.CLI.Core/Vault/VaultUnavailableException.cs new file mode 100644 index 0000000..5e3fd90 --- /dev/null +++ b/src/TALXIS.CLI.Core/Vault/VaultUnavailableException.cs @@ -0,0 +1,30 @@ +namespace TALXIS.CLI.Core.Vault; + +/// +/// Thrown when the OS credential vault (DPAPI / Keychain / libsecret) cannot be +/// initialised or verified. Surfaces a user-facing remedy string describing how +/// to unblock the situation (install libsecret-1-0, opt in to a plaintext +/// fallback, etc.). +/// +public sealed class VaultUnavailableException : Exception +{ + /// + /// Canonical remedy message shown to the user when the vault fails to + /// persist. Kept as a constant so tests can assert on it verbatim and it + /// stays in sync with the README. + /// + public const string RemedyMessage = + "OS credential vault is unavailable. On Linux install `libsecret-1-0` " + + "and `gnome-keyring` (or run inside a desktop session with D-Bus). To " + + "opt in to a plaintext file (chmod 600) fallback, re-run with " + + "`--plaintext-fallback` or set `TXC_PLAINTEXT_FALLBACK=1`."; + + public VaultUnavailableException() + : base(RemedyMessage) { } + + public VaultUnavailableException(Exception inner) + : base(RemedyMessage, inner) { } + + public VaultUnavailableException(string message, Exception? inner = null) + : base(message, inner) { } +} diff --git a/src/TALXIS.CLI.Data/DataPackageImportCliCommand.cs b/src/TALXIS.CLI.Data/DataPackageImportCliCommand.cs deleted file mode 100644 index ef44626..0000000 --- a/src/TALXIS.CLI.Data/DataPackageImportCliCommand.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.ComponentModel; -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Logging; -using TALXIS.CLI.XrmTools; - -namespace TALXIS.CLI.Data; - -[CliCommand( - Name = "import", - Description = "Import a CMT data package into a Dataverse environment" -)] -public class DataPackageImportCliCommand -{ - private readonly CmtImportRunner _cmtImportRunner = new(); - private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(DataPackageImportCliCommand)); - - [CliArgument(Description = "Path to the CMT data package (.zip file or folder containing data.xml and data_schema.xml)")] - public required string Data { get; set; } - - [CliOption(Name = "--connection-string", Description = "Dataverse connection string. If omitted, txc checks DATAVERSE_CONNECTION_STRING and TXC_DATAVERSE_CONNECTION_STRING.", Required = false)] - public string? ConnectionString { get; set; } - - [CliOption(Name = "--environment", Description = "Dataverse environment URL for interactive sign-in when no connection string is provided.", Required = false)] - public string? EnvironmentUrl { get; set; } - - [CliOption(Name = "--device-code", Description = "Use Microsoft Entra device code flow instead of opening a browser for interactive sign-in.", Required = false)] - public bool DeviceCode { get; set; } - - [CliOption(Name = "--connection-count", Description = "Number of parallel connections for data import.", Required = false)] - [DefaultValue(1)] - public int ConnectionCount { get; set; } = 1; - - [CliOption(Name = "--verbose", Description = "Enable verbose CMT logging.", Required = false)] - public bool Verbose { get; set; } - - public async Task RunAsync() - { - if (string.IsNullOrWhiteSpace(Data)) - { - _logger.LogError("A path to a CMT data package (.zip or folder) must be provided."); - return 1; - } - - if (!File.Exists(Data) && !Directory.Exists(Data)) - { - _logger.LogError("Data package not found: {DataPath}", Data); - return 1; - } - - string? resolvedConnectionString = ResolveConnectionString(ConnectionString); - string? resolvedEnvironmentUrl = ResolveEnvironmentUrl(EnvironmentUrl); - - if (string.IsNullOrWhiteSpace(resolvedConnectionString) && string.IsNullOrWhiteSpace(resolvedEnvironmentUrl)) - { - _logger.LogError("Dataverse authentication is required. Pass --connection-string, pass --environment for interactive sign-in, or set DATAVERSE_CONNECTION_STRING / TXC_DATAVERSE_CONNECTION_STRING / DATAVERSE_ENVIRONMENT_URL / TXC_DATAVERSE_ENVIRONMENT_URL."); - return 1; - } - - try - { - CmtImportResult result = await _cmtImportRunner.RunAsync(new CmtImportRequest( - Path.GetFullPath(Data), - resolvedConnectionString, - resolvedEnvironmentUrl, - DeviceCode, - ConnectionCount, - Verbose)); - - if (!result.Succeeded) - { - if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) - { - _logger.LogError("{ErrorMessage}", result.ErrorMessage); - } - - _logger.LogError("Data import failed. Data package: {DataPath}", Path.GetFullPath(Data)); - return 1; - } - - _logger.LogInformation("Data import completed successfully."); - return 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "Data import failed"); - _logger.LogError("Data package: {DataPath}", Path.GetFullPath(Data)); - return 1; - } - } - - private static string? ResolveConnectionString(string? optionValue) - { - if (!string.IsNullOrWhiteSpace(optionValue)) - { - return optionValue; - } - - return Environment.GetEnvironmentVariable("DATAVERSE_CONNECTION_STRING") - ?? Environment.GetEnvironmentVariable("TXC_DATAVERSE_CONNECTION_STRING"); - } - - private static string? ResolveEnvironmentUrl(string? optionValue) - { - if (!string.IsNullOrWhiteSpace(optionValue)) - { - return optionValue; - } - - return Environment.GetEnvironmentVariable("DATAVERSE_ENVIRONMENT_URL") - ?? Environment.GetEnvironmentVariable("TXC_DATAVERSE_ENVIRONMENT_URL"); - } -} diff --git a/src/TALXIS.CLI.Dataverse/DataverseAuthTokenProvider.cs b/src/TALXIS.CLI.Dataverse/DataverseAuthTokenProvider.cs deleted file mode 100644 index 5d40cb5..0000000 --- a/src/TALXIS.CLI.Dataverse/DataverseAuthTokenProvider.cs +++ /dev/null @@ -1,154 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Identity.Client; -using Microsoft.Identity.Client.Extensions.Msal; -using TALXIS.CLI.Logging; - -namespace TALXIS.CLI.Dataverse; - -/// -/// MSAL-based access token provider for Dataverse. Uses the same public client ID -/// and redirect URI that PAC CLI uses so users don't need to register their own -/// application. Supports silent acquisition, device-code, and interactive fallback. -/// -public sealed class DataverseAuthTokenProvider : IDisposable -{ - private static readonly Guid PacClientId = new("9cee029c-6210-4654-90bb-17e6e9d36617"); - private static readonly Uri PacRedirectUri = new("http://localhost"); - private static readonly TimeSpan RefreshSkew = TimeSpan.FromMinutes(5); - - private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(DataverseAuthTokenProvider)); - private readonly IPublicClientApplication _publicClientApplication; - private readonly SemaphoreSlim _tokenLock = new(1, 1); - private readonly bool _deviceCode; - private readonly bool _verbose; - private AuthenticationResult? _lastAuthenticationResult; - - public DataverseAuthTokenProvider(Uri environmentUrl, bool deviceCode, bool verbose) - { - ArgumentNullException.ThrowIfNull(environmentUrl); - - _deviceCode = deviceCode; - _verbose = verbose; - - string cacheDirectory = Path.Combine( - System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), - ".txc", - "auth"); - - Directory.CreateDirectory(cacheDirectory); - - _publicClientApplication = PublicClientApplicationBuilder - .Create(PacClientId.ToString()) - .WithRedirectUri(PacRedirectUri.ToString()) - .WithAuthority(ResolveAuthority(environmentUrl).ToString()) - .Build(); - - RegisterTokenCache(cacheDirectory); - } - - public Task GetAccessTokenAsync(Uri resourceUri, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(resourceUri); - return AcquireAccessTokenAsync(resourceUri, cancellationToken); - } - - public void Dispose() - { - _tokenLock.Dispose(); - } - - public static string BuildDefaultScope(Uri resourceUri) - { - ArgumentNullException.ThrowIfNull(resourceUri); - return resourceUri.GetLeftPart(UriPartial.Authority) + "//.default"; - } - - public static Uri ResolveAuthority(Uri environmentUrl) - { - ArgumentNullException.ThrowIfNull(environmentUrl); - - string host = environmentUrl.Host.ToLowerInvariant(); - if (host.EndsWith(".crm.dynamics.us", StringComparison.Ordinal) || host.EndsWith(".crm.appsplatform.us", StringComparison.Ordinal)) - { - return new Uri("https://login.microsoftonline.us/organizations"); - } - - if (host.EndsWith(".crm.dynamics.cn", StringComparison.Ordinal)) - { - return new Uri("https://login.partner.microsoftonline.cn/organizations"); - } - - return new Uri("https://login.microsoftonline.com/organizations"); - } - - private async Task AcquireAccessTokenAsync(Uri resourceUri, CancellationToken cancellationToken) - { - await _tokenLock.WaitAsync(cancellationToken).ConfigureAwait(false); - - try - { - if (_lastAuthenticationResult is not null && - _lastAuthenticationResult.ExpiresOn > DateTimeOffset.UtcNow.Add(RefreshSkew)) - { - return _lastAuthenticationResult.AccessToken; - } - - string[] scopes = [BuildDefaultScope(resourceUri)]; - IEnumerable accounts = await _publicClientApplication.GetAccountsAsync().ConfigureAwait(false); - - foreach (IAccount account in accounts) - { - try - { - _lastAuthenticationResult = await _publicClientApplication - .AcquireTokenSilent(scopes, account) - .ExecuteAsync(cancellationToken) - .ConfigureAwait(false); - - return _lastAuthenticationResult.AccessToken; - } - catch (MsalUiRequiredException) - { - } - } - - _lastAuthenticationResult = _deviceCode - ? await _publicClientApplication - .AcquireTokenWithDeviceCode(scopes, OnDeviceCodeReceivedAsync) - .ExecuteAsync(cancellationToken) - .ConfigureAwait(false) - : await _publicClientApplication - .AcquireTokenInteractive(scopes) - .WithPrompt(Prompt.SelectAccount) - .ExecuteAsync(cancellationToken) - .ConfigureAwait(false); - - return _lastAuthenticationResult.AccessToken; - } - finally - { - _tokenLock.Release(); - } - } - - private Task OnDeviceCodeReceivedAsync(DeviceCodeResult deviceCodeResult) - { - _logger.LogInformation("{DeviceCodeMessage}", deviceCodeResult.Message); - if (_verbose) - { - _logger.LogInformation("Waiting for device code authentication to complete..."); - } - - return Task.CompletedTask; - } - - private void RegisterTokenCache(string cacheDirectory) - { - StorageCreationProperties storageProperties = new StorageCreationPropertiesBuilder("dataverse-auth-cache.bin", cacheDirectory) - .WithMacKeyChain("com.talxis.txc.dataverse", "dataverse-auth-cache") - .Build(); - - MsalCacheHelper cacheHelper = MsalCacheHelper.CreateAsync(storageProperties).GetAwaiter().GetResult(); - cacheHelper.RegisterCache(_publicClientApplication.UserTokenCache); - } -} diff --git a/src/TALXIS.CLI.Dataverse/ServiceClientFactory.cs b/src/TALXIS.CLI.Dataverse/ServiceClientFactory.cs deleted file mode 100644 index 0ace834..0000000 --- a/src/TALXIS.CLI.Dataverse/ServiceClientFactory.cs +++ /dev/null @@ -1,165 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.PowerPlatform.Dataverse.Client; - -namespace TALXIS.CLI.Dataverse; - -/// -/// Disposable wrapper around a plus its optional -/// . Disposing releases both. -/// -public sealed class DataverseConnection : IDisposable -{ - public ServiceClient Client { get; } - public DataverseAuthTokenProvider? TokenProvider { get; } - - internal DataverseConnection(ServiceClient client, DataverseAuthTokenProvider? tokenProvider) - { - Client = client; - TokenProvider = tokenProvider; - } - - public void Dispose() - { - Client.Dispose(); - TokenProvider?.Dispose(); - } -} - -/// -/// Centralised construction of instances from either an -/// explicit connection string or an environment URL combined with the PAC-style MSAL -/// auth flow surfaced by . Honours the -/// env-var fallbacks used by the package deploy command. -/// -public static class ServiceClientFactory -{ - public const string ConnectionStringEnvVar = "DATAVERSE_CONNECTION_STRING"; - public const string ConnectionStringTxcEnvVar = "TXC_DATAVERSE_CONNECTION_STRING"; - public const string EnvironmentUrlEnvVar = "DATAVERSE_ENVIRONMENT_URL"; - public const string EnvironmentUrlTxcEnvVar = "TXC_DATAVERSE_ENVIRONMENT_URL"; - - public static string? ResolveConnectionString(string? optionValue) - { - if (!string.IsNullOrWhiteSpace(optionValue)) - { - return optionValue; - } - - return System.Environment.GetEnvironmentVariable(ConnectionStringEnvVar) - ?? System.Environment.GetEnvironmentVariable(ConnectionStringTxcEnvVar); - } - - public static string? ResolveEnvironmentUrl(string? optionValue) - { - if (!string.IsNullOrWhiteSpace(optionValue)) - { - return optionValue; - } - - return System.Environment.GetEnvironmentVariable(EnvironmentUrlEnvVar) - ?? System.Environment.GetEnvironmentVariable(EnvironmentUrlTxcEnvVar); - } - - /// - /// Builds a from the resolved connection string, or — when only - /// an environment URL is known — via the MSAL-based token provider. - /// - /// Optional connection string (already resolved from options/env). - /// Optional environment URL (already resolved from options/env). - /// Use device-code flow instead of interactive browser. - /// Emit verbose auth traces. - /// Optional logger forwarded to the SDK. - /// - /// Out parameter carrying the underlying - /// when auth is performed via environment URL. Caller owns disposal. - /// - public static ServiceClient Create( - string? connectionString, - string? environmentUrl, - bool deviceCode, - bool verbose, - ILogger? logger, - out DataverseAuthTokenProvider? tokenProvider) - { - if (!string.IsNullOrWhiteSpace(connectionString)) - { - tokenProvider = null; - var client = new ServiceClient(connectionString, logger); - ThrowIfNotReady(client); - return client; - } - - if (string.IsNullOrWhiteSpace(environmentUrl)) - { - throw new InvalidOperationException( - "Dataverse authentication requires either --connection-string or --environment (or the DATAVERSE_CONNECTION_STRING / DATAVERSE_ENVIRONMENT_URL env vars)."); - } - - if (!Uri.TryCreate(environmentUrl, UriKind.Absolute, out Uri? instanceUri)) - { - throw new InvalidOperationException( - $"Invalid Dataverse environment URL '{environmentUrl}'. Pass a valid absolute URL to --environment or set DATAVERSE_ENVIRONMENT_URL / TXC_DATAVERSE_ENVIRONMENT_URL."); - } - - var provider = new DataverseAuthTokenProvider(instanceUri, deviceCode, verbose); - tokenProvider = provider; - - try - { - // ServiceClient token provider delegate receives the target resource URL and must - // return a bearer token. Delegate to the MSAL-backed provider which handles - // silent → device-code/interactive fallback and disk caching. - var client = new ServiceClient( - instanceUri, - resourceUrl => provider.GetAccessTokenAsync(new Uri(resourceUrl)), - useUniqueInstance: true, - logger); - - ThrowIfNotReady(client); - return client; - } - catch - { - provider.Dispose(); - throw; - } - } - - private static void ThrowIfNotReady(ServiceClient client) - { - if (!client.IsReady) - { - string message = client.LastError; - client.Dispose(); - throw new InvalidOperationException( - $"Failed to establish Dataverse connection: {message}"); - } - } - - /// - /// One-call resolve-and-connect. Handles env-var fallbacks, validates that auth - /// is configured, and returns a disposable wrapper owning both the - /// and optional token provider. - /// Throws when neither a connection - /// string nor an environment URL can be resolved. - /// - public static DataverseConnection Connect( - string? connectionStringOption, - string? environmentUrlOption, - bool deviceCode, - bool verbose, - ILogger? logger) - { - string? resolvedConnectionString = ResolveConnectionString(connectionStringOption); - string? resolvedEnvironmentUrl = ResolveEnvironmentUrl(environmentUrlOption); - - if (string.IsNullOrWhiteSpace(resolvedConnectionString) && string.IsNullOrWhiteSpace(resolvedEnvironmentUrl)) - { - throw new InvalidOperationException( - "Dataverse authentication is required. Pass --connection-string, pass --environment for interactive sign-in, or set DATAVERSE_CONNECTION_STRING / TXC_DATAVERSE_CONNECTION_STRING / DATAVERSE_ENVIRONMENT_URL / TXC_DATAVERSE_ENVIRONMENT_URL."); - } - - var client = Create(resolvedConnectionString, resolvedEnvironmentUrl, deviceCode, verbose, logger, out var tokenProvider); - return new DataverseConnection(client, tokenProvider); - } -} diff --git a/src/TALXIS.CLI.Environment/Deployment/DeploymentShowCliCommand.cs b/src/TALXIS.CLI.Environment/Deployment/DeploymentShowCliCommand.cs deleted file mode 100644 index eb17044..0000000 --- a/src/TALXIS.CLI.Environment/Deployment/DeploymentShowCliCommand.cs +++ /dev/null @@ -1,607 +0,0 @@ -using System.Text.Json; -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using Microsoft.PowerPlatform.Dataverse.Client; -using Microsoft.Xrm.Sdk; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment.Platforms.Dataverse; -using TALXIS.CLI.Logging; -using TALXIS.CLI.Shared; - -namespace TALXIS.CLI.Environment.Deployment; - -/// -/// Shows details for a single deployment run (package or solution). Resolution is driven by -/// typed selectors — each selector maps to exactly one platform entity, with no cross-entity -/// probing. Emits findings derived from . -/// -[CliCommand( - Name = "show", - Description = "Show details and findings for a single deployment run. Specify exactly one of --package-run-id, --solution-run-id, --async-operation-id, --package-name, --solution-name, or --latest." -)] -public class DeploymentShowCliCommand -{ - // Tail buffer added after package completion to catch async solution imports that finish - // slightly after Package Deployer signals done. - private static readonly TimeSpan CorrelationTailBuffer = TimeSpan.FromSeconds(30); - - private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(DeploymentShowCliCommand)); - - [CliOption(Name = "--package-run-id", Description = "GUID of a package deployment run (packagehistory row).", Required = false)] - public string? PackageRunId { get; set; } - - [CliOption(Name = "--solution-run-id", Description = "GUID of a solution import run (msdyn_solutionhistory row).", Required = false)] - public string? SolutionRunId { get; set; } - - [CliOption(Name = "--async-operation-id", Description = "GUID of the async operation returned by a queued solution import. Falls back to the correlated solution history row, then to raw async-op status.", Required = false)] - public string? AsyncOperationId { get; set; } - - [CliOption(Name = "--package-name", Description = "NuGet package name — returns the most recent run in packagehistory matching this name.", Required = false)] - public string? PackageName { get; set; } - - [CliOption(Name = "--solution-name", Description = "Solution unique name — returns the most recent standalone solution import matching this name.", Required = false)] - public string? SolutionName { get; set; } - - [CliOption(Name = "--latest", Description = "Show the most recent deployment run across packages and solutions.", Required = false)] - public bool Latest { get; set; } - - [CliOption(Name = "--connection-string", Description = "Dataverse connection string. If omitted, txc checks DATAVERSE_CONNECTION_STRING and TXC_DATAVERSE_CONNECTION_STRING.", Required = false)] - public string? ConnectionString { get; set; } - - [CliOption(Name = "--environment", Description = "Dataverse environment URL for interactive sign-in when no connection string is provided.", Required = false)] - public string? EnvironmentUrl { get; set; } - - [CliOption(Name = "--device-code", Description = "Use Microsoft Entra device code flow instead of opening a browser for interactive sign-in.", Required = false)] - public bool DeviceCode { get; set; } - - [CliOption(Name = "--full", Description = "Include every correlated solution and the formatted import log (solution mode). Default output is compact.", Required = false)] - public bool Full { get; set; } - - [CliOption(Name = "--json", Description = "Emit the full structured record as indented JSON (always unbounded).", Required = false)] - public bool Json { get; set; } - - [CliOption(Name = "--verbose", Description = "Enable verbose logging.", Required = false)] - public bool Verbose { get; set; } - - public async Task RunAsync() - { - int specified = - (PackageRunId is not null ? 1 : 0) + - (SolutionRunId is not null ? 1 : 0) + - (AsyncOperationId is not null ? 1 : 0) + - (PackageName is not null ? 1 : 0) + - (SolutionName is not null ? 1 : 0) + - (Latest ? 1 : 0); - - if (specified == 0) - { - _logger.LogError("Specify exactly one of --package-run-id, --solution-run-id, --async-operation-id, --package-name, --solution-name, or --latest."); - return 1; - } - if (specified > 1) - { - _logger.LogError("--package-run-id, --solution-run-id, --async-operation-id, --package-name, --solution-name, and --latest are mutually exclusive. Specify only one."); - return 1; - } - - Guid packageRunGuid = Guid.Empty; - Guid solutionRunGuid = Guid.Empty; - Guid asyncOpGuid = Guid.Empty; - - if (PackageRunId is not null && !TryParseGuid(PackageRunId, "--package-run-id", out packageRunGuid)) return 1; - if (SolutionRunId is not null && !TryParseGuid(SolutionRunId, "--solution-run-id", out solutionRunGuid)) return 1; - if (AsyncOperationId is not null && !TryParseGuid(AsyncOperationId, "--async-operation-id", out asyncOpGuid)) return 1; - - if (PackageName is not null && string.IsNullOrWhiteSpace(PackageName)) - { - _logger.LogError("--package-name must not be empty."); - return 1; - } - if (SolutionName is not null && string.IsNullOrWhiteSpace(SolutionName)) - { - _logger.LogError("--solution-name must not be empty."); - return 1; - } - - DataverseConnection conn; - try - { - conn = ServiceClientFactory.Connect(ConnectionString, EnvironmentUrl, DeviceCode, Verbose, _logger); - } - catch (InvalidOperationException ex) - { - _logger.LogError("{Error}", ex.Message); - return 1; - } - - using (conn) - { - var client = conn.Client; - try - { - var pkgReader = new PackageHistoryReader(client, _logger); - var solReader = new SolutionHistoryReader(client, _logger); - - if (PackageRunId is not null) - { - var pkg = await pkgReader.GetByIdAsync(packageRunGuid).ConfigureAwait(false); - if (pkg is null) - { - _logger.LogError("No package run matched --package-run-id '{Id}'.", PackageRunId); - return 1; - } - return await RenderPackageAsync(client, pkg).ConfigureAwait(false); - } - - if (SolutionRunId is not null) - { - var sol = await solReader.GetByIdAsync(solutionRunGuid).ConfigureAwait(false); - if (sol is null) - { - _logger.LogError("No solution run matched --solution-run-id '{Id}'.", SolutionRunId); - return 1; - } - return await RenderSolutionAsync(client, sol).ConfigureAwait(false); - } - - if (AsyncOperationId is not null) - { - var sol = await solReader.GetByActivityIdAsync(asyncOpGuid).ConfigureAwait(false); - if (sol is not null) - { - return await RenderSolutionAsync(client, sol).ConfigureAwait(false); - } - int? asyncResult = await TryShowAsyncOperationAsync(client, asyncOpGuid).ConfigureAwait(false); - if (asyncResult.HasValue) return asyncResult.Value; - _logger.LogError("No async operation or correlated solution run matched --async-operation-id '{Id}'.", AsyncOperationId); - return 1; - } - - if (PackageName is not null) - { - var pkg = await pkgReader.GetLatestAsync(PackageName!.Trim()).ConfigureAwait(false); - if (pkg is null) - { - _logger.LogError("No package run matched --package-name '{Name}'.", PackageName); - return 1; - } - return await RenderPackageAsync(client, pkg).ConfigureAwait(false); - } - - if (SolutionName is not null) - { - var sol = await solReader.GetLatestByNameAsync(SolutionName!.Trim()).ConfigureAwait(false); - if (sol is null) - { - _logger.LogError("No solution run matched --solution-name '{Name}'.", SolutionName); - return 1; - } - return await RenderSolutionAsync(client, sol).ConfigureAwait(false); - } - - // --latest - var pkgTask = pkgReader.GetRecentAsync(1); - var solTask = solReader.GetRecentAsync(1); - await Task.WhenAll(pkgTask, solTask).ConfigureAwait(false); - var latestPkg = (await pkgTask.ConfigureAwait(false)).FirstOrDefault(); - var latestSol = (await solTask.ConfigureAwait(false)).FirstOrDefault(); - if (latestPkg is null && latestSol is null) - { - _logger.LogError("No deployment runs found."); - return 1; - } - var pkgTime = latestPkg?.StartedAtUtc ?? DateTime.MinValue; - var solTime = latestSol?.StartedAtUtc ?? DateTime.MinValue; - if (latestPkg is not null && (latestSol is null || pkgTime >= solTime)) - { - return await RenderPackageAsync(client, latestPkg).ConfigureAwait(false); - } - return await RenderSolutionAsync(client, latestSol!).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "environment deployment show failed"); - return 1; - } - } - } - - private bool TryParseGuid(string value, string optionName, out Guid guid) - { - if (Guid.TryParse(value, out guid)) return true; - _logger.LogError("{Option} must be a full GUID.", optionName); - return false; - } - - private async Task RenderPackageAsync(ServiceClient client, PackageHistoryRecord record) - { - var historyReader = new SolutionHistoryReader(client, _logger); - IReadOnlyList correlated = Array.Empty(); - if (record.StartedAtUtc is { } startedAt) - { - try - { - // Preferred path: exact join via asyncoperation.correlationid. - if (record.CorrelationId is { } corrId && corrId != Guid.Empty) - { - correlated = await historyReader.GetByCorrelationIdAsync(corrId).ConfigureAwait(false); - } - - // Fallback: time window when asyncoperation records have been cleaned up. - if (correlated.Count == 0) - { - var windowEnd = (record.CompletedAtUtc ?? startedAt) + CorrelationTailBuffer; - correlated = await historyReader.GetInTimeWindowAsync(startedAt, windowEnd).ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to enrich package run with solution history."); - } - } - - var findings = DeploymentFindingsAnalyzer.Analyze(new DeploymentFindingsInput - { - ImportJobData = null, - Primary = null, - Solutions = correlated, - IsPackageMode = true, - IncludeSolutions = true, - PackageStatus = record.Status, - PackageStartedAtUtc = record.StartedAtUtc, - }); - - if (Json) - { - OutputWriter.WriteLine(JsonSerializer.Serialize(new - { - kind = "package", - id = record.Id, - name = record.Name, - status = record.Status, - stage = record.Stage, - startedAtUtc = record.StartedAtUtc?.ToString("O"), - completedAtUtc = record.CompletedAtUtc?.ToString("O"), - operationId = record.OperationId, - correlationId = record.CorrelationId, - message = record.Message, - solutions = correlated.Select(s => new - { - id = s.Id, - activityId = s.ActivityId, - solutionName = s.SolutionName, - solutionVersion = s.SolutionVersion, - operation = s.OperationLabel, - operationCode = s.OperationCode, - suboperation = s.SuboperationLabel, - suboperationCode = s.SuboperationCode, - overwriteUnmanagedCustomizations = s.OverwriteUnmanagedCustomizations, - startedAtUtc = s.StartedAtUtc?.ToString("O"), - completedAtUtc = s.CompletedAtUtc?.ToString("O"), - result = s.Result, - }).ToList(), - findings, - }, JsonOptions)); - return 0; - } - - PrintPackage(record, correlated); - WriteFindings(Console.Out, findings); - return 0; - } - - private async Task RenderSolutionAsync(ServiceClient client, SolutionHistoryRecord record) - { - PackageHistoryRecord? parentPackage = null; - if (record.StartedAtUtc is { } startedAt) - { - try - { - var pkgReader = new PackageHistoryReader(client, _logger); - var nearby = await pkgReader.GetRecentAsync(50, startedAt - CorrelationTailBuffer, problemsOnly: false).ConfigureAwait(false); - parentPackage = nearby.FirstOrDefault(p => - p.StartedAtUtc is { } ps - && ps <= startedAt - && ((p.CompletedAtUtc ?? ps) + CorrelationTailBuffer) >= startedAt); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to locate parent package for solution history row."); - } - } - - string? formattedLog = null; - ImportJobRecord? importJobMatch = null; - if (Full) - { - try - { - var importReader = new ImportJobReader(client, _logger); - if (record.StartedAtUtc is { } startedAt2) - { - var windowStart = startedAt2; - var windowEnd = (record.CompletedAtUtc ?? startedAt2) + CorrelationTailBuffer; - var jobs = await importReader.GetInTimeWindowAsync(windowStart, windowEnd).ConfigureAwait(false); - importJobMatch = jobs.FirstOrDefault(j => - record.SolutionName is not null - && string.Equals(j.SolutionName, record.SolutionName, StringComparison.OrdinalIgnoreCase)); - if (importJobMatch is not null) - { - formattedLog = await importReader.GetFormattedResultsAsync(importJobMatch.Id).ConfigureAwait(false); - } - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to retrieve formatted import log for solution history row."); - } - } - - var findings = DeploymentFindingsAnalyzer.Analyze(new DeploymentFindingsInput - { - ImportJobData = importJobMatch?.Data, - Primary = record, - Solutions = new[] { record }, - IsPackageMode = false, - IncludeSolutions = false, - }); - - if (Json) - { - OutputWriter.WriteLine(JsonSerializer.Serialize(new - { - kind = "solution", - id = record.Id, - solutionName = record.SolutionName, - solutionVersion = record.SolutionVersion, - packageName = record.PackageName, - operation = record.OperationLabel, - operationCode = record.OperationCode, - suboperation = record.SuboperationLabel, - suboperationCode = record.SuboperationCode, - overwriteUnmanagedCustomizations = record.OverwriteUnmanagedCustomizations, - startedAtUtc = record.StartedAtUtc?.ToString("O"), - completedAtUtc = record.CompletedAtUtc?.ToString("O"), - result = record.Result, - parentPackage = parentPackage is null ? null : new - { - id = parentPackage.Id, - name = parentPackage.Name, - status = parentPackage.Status, - }, - formattedImportLog = formattedLog, - findings, - }, JsonOptions)); - return 0; - } - - PrintSolution(record, parentPackage); - if (Full && formattedLog is not null) - { - OutputWriter.WriteLine(); - OutputWriter.WriteLine("-- formatted import log --"); - OutputWriter.WriteLine(formattedLog); - } - WriteFindings(Console.Out, findings); - return 0; - } - - /// - /// Attempts to resolve and display status for an asyncoperation row directly. - /// Returns the exit code (0 = success, 1 = import failed) when the GUID is a known async - /// operation ID, or null when the GUID does not correspond to any async operation. - /// - private async Task TryShowAsyncOperationAsync(ServiceClient client, Guid asyncOpId) - { - Entity entity; - try - { - entity = await client.RetrieveAsync( - DataverseSchema.AsyncOperation.EntityName, - asyncOpId, - new Microsoft.Xrm.Sdk.Query.ColumnSet( - "statecode", "statuscode", "message", "friendlymessage", "createdon", "completedon"), - default).ConfigureAwait(false); - } - catch (Exception ex) when (IsNotFoundError(ex)) - { - return null; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to retrieve asyncoperation {Id}.", asyncOpId); - return null; - } - - var statecode = entity.GetAttributeValue("statecode")?.Value ?? 0; - var statuscode = entity.GetAttributeValue("statuscode")?.Value ?? 0; - - // Not yet completed — show live status. - if (statecode != 3) - { - string stateLabel = statecode switch - { - 0 => "Ready", - 1 => "Suspended", - 2 => "In Progress", - _ => $"State {statecode}", - }; - - if (Json) - { - OutputWriter.WriteLine(JsonSerializer.Serialize(new - { - kind = "asyncoperation", - id = asyncOpId, - state = stateLabel, - statecode, - statuscode, - completed = false, - }, JsonOptions)); - } - else - { - OutputWriter.WriteLine($"Import in progress: {stateLabel}"); - OutputWriter.WriteLine($" asyncOperationId: {asyncOpId}"); - OutputWriter.WriteLine($" Run again to refresh or use `txc environment deployment show --async-operation-id {asyncOpId}` when done."); - } - return 0; - } - - bool succeededOp = statuscode == 30; - DateTime? completedOn = entity.Contains("completedon") - ? entity.GetAttributeValue("completedon") - : null; - DateTime? createdOn = entity.Contains("createdon") - ? entity.GetAttributeValue("createdon") - : null; - - var historyReader = new SolutionHistoryReader(client, _logger); - DateTime pivot = completedOn ?? createdOn ?? DateTime.UtcNow; - SolutionHistoryRecord? sol = null; - - bool veryRecent = (DateTime.UtcNow - pivot).TotalSeconds < 60; - int attempts = veryRecent ? 3 : 1; - for (int i = 0; i < attempts; i++) - { - if (i > 0) - { - await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); - } - sol = await historyReader.GetByActivityIdAsync(asyncOpId, nearUtc: pivot).ConfigureAwait(false); - if (sol is not null) break; - } - - if (sol is not null) - { - return await RenderSolutionAsync(client, sol).ConfigureAwait(false); - } - - string? message = entity.GetAttributeValue("friendlymessage") - ?? entity.GetAttributeValue("message"); - - if (Json) - { - OutputWriter.WriteLine(JsonSerializer.Serialize(new - { - kind = "asyncoperation", - id = asyncOpId, - state = "Completed", - statecode, - statuscode, - completed = true, - succeeded = succeededOp, - message, - }, JsonOptions)); - } - else - { - OutputWriter.WriteLine($"Async operation {asyncOpId}"); - OutputWriter.WriteLine($" state: Completed"); - OutputWriter.WriteLine($" result: {(succeededOp ? "Succeeded" : $"Failed (status {statuscode})")}"); - if (!string.IsNullOrWhiteSpace(message)) - { - OutputWriter.WriteLine($" message: {message}"); - } - OutputWriter.WriteLine(" (Solution history record not yet available — re-run shortly to get full details.)"); - } - - return succeededOp ? 0 : 1; - } - - private static bool IsNotFoundError(Exception ex) - { - if (ex.Message.Contains("0x80040217", StringComparison.OrdinalIgnoreCase)) return true; - if (ex.Message.Contains("Does Not Exist", StringComparison.OrdinalIgnoreCase)) return true; - if (ex.Message.Contains("ObjectDoesNotExist", StringComparison.OrdinalIgnoreCase)) return true; - return false; - } - - private static void PrintPackage(PackageHistoryRecord record, IReadOnlyList correlated) - { - OutputWriter.WriteLine($"Package: {record.Name ?? "(unknown)"}"); - OutputWriter.WriteLine($" id: {record.Id}"); - OutputWriter.WriteLine($" status: {record.Status ?? "(unknown)"}"); - bool completed = string.Equals(record.Status, "Completed", StringComparison.OrdinalIgnoreCase); - if (!completed && record.Stage is not null) - { - OutputWriter.WriteLine($" stage: {record.Stage}"); - } - OutputWriter.WriteLine($" started (UTC): {FormatUtc(record.StartedAtUtc)}"); - if (record.CompletedAtUtc is not null) - { - OutputWriter.WriteLine($" completed (UTC): {FormatUtc(record.CompletedAtUtc)}"); - } - if (record.StartedAtUtc is { } s && record.CompletedAtUtc is { } e) - { - OutputWriter.WriteLine($" duration: {FormatDuration(e - s)}"); - } - if (!string.IsNullOrWhiteSpace(record.Message)) - { - OutputWriter.WriteLine($" message: {record.Message}"); - } - - OutputWriter.WriteLine(); - OutputWriter.WriteLine($"Solutions within package run window: {correlated.Count}"); - if (correlated.Count == 0) - { - return; - } - - foreach (var solution in correlated) - { - string duration = (solution.StartedAtUtc is { } start && solution.CompletedAtUtc is { } end) - ? FormatDuration(end - start) - : "(unknown)"; - OutputWriter.WriteLine($" - {solution.SolutionName ?? "(unknown)"} | {solution.SuboperationLabel} | {duration}"); - } - } - - private static void PrintSolution(SolutionHistoryRecord record, PackageHistoryRecord? parent) - { - string context = parent is null - ? "(standalone import)" - : $"(part of package: {parent.Id.ToString("N")[..8]} {parent.Name})"; - - OutputWriter.WriteLine($"Solution: {record.SolutionName ?? "(unknown)"} {context}"); - OutputWriter.WriteLine($" id: {record.Id}"); - OutputWriter.WriteLine($" version: {record.SolutionVersion ?? "(unknown)"}"); - OutputWriter.WriteLine($" operation: {record.OperationLabel} / {record.SuboperationLabel}"); - if (record.OverwriteUnmanagedCustomizations is { } overwrite) - { - OutputWriter.WriteLine($" overwrite: {(overwrite ? "yes" : "no")}"); - } - OutputWriter.WriteLine($" started (UTC): {FormatUtc(record.StartedAtUtc)}"); - OutputWriter.WriteLine($" completed (UTC): {FormatUtc(record.CompletedAtUtc)}"); - if (record.StartedAtUtc is { } s && record.CompletedAtUtc is { } e) - { - OutputWriter.WriteLine($" duration: {FormatDuration(e - s)}"); - } - if (!string.IsNullOrWhiteSpace(record.Result)) - { - OutputWriter.WriteLine($" result: {record.Result}"); - } - } - - private static void WriteFindings(TextWriter writer, IReadOnlyList findings) - { - if (findings is null || findings.Count == 0) return; - writer.WriteLine(); - writer.WriteLine("Findings:"); - foreach (var f in findings) - { - writer.WriteLine($"- {f}"); - } - } - - private static string FormatUtc(DateTime? value) => value is null ? "(unknown)" : value.Value.ToString("O"); - - private static string FormatDuration(TimeSpan span) => span.TotalSeconds < 60 - ? $"{span.TotalSeconds:0.#}s" - : $"{(int)span.TotalMinutes}m {span.Seconds}s"; - - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true, - }; -} diff --git a/src/TALXIS.CLI.Environment/Package/PackageImportCliCommand.cs b/src/TALXIS.CLI.Environment/Package/PackageImportCliCommand.cs deleted file mode 100644 index 136de4e..0000000 --- a/src/TALXIS.CLI.Environment/Package/PackageImportCliCommand.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System.ComponentModel; -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment.Platforms.Dataverse; -using TALXIS.CLI.Logging; -using TALXIS.CLI.XrmTools; - -namespace TALXIS.CLI.Environment.Package; - -[CliCommand( - Name = "import", - Description = "Import a deployable package into the target environment." -)] -public class PackageImportCliCommand -{ - private readonly NuGetPackageInstallerService _packageInstaller = new(); - private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(PackageImportCliCommand)); - - [CliArgument(Name = "package", Description = "NuGet package name, local .pdpkg.zip/.pdpkg/.zip archive path, or extracted package folder path.", Required = true)] - public required string Package { get; set; } - - [CliOption(Name = "--version", Description = "NuGet package version (only when 'package' is a NuGet name).", Required = false)] - [DefaultValue("latest")] - public string PackageVersion { get; set; } = "latest"; - - [CliOption(Name = "--output", Aliases = ["-o"], Description = "Download/extract output directory.", Required = false)] - public string? OutputDirectory { get; set; } - - [CliOption(Name = "--download-only", Description = "Download/extract without running Package Deployer.", Required = false)] - public bool DownloadOnly { get; set; } - - [CliOption(Name = "--settings", Description = "Runtime settings string for Package Deployer.", Required = false)] - public string? Settings { get; set; } - - [CliOption(Name = "--log-file", Description = "Path to Package Deployer log file.", Required = false)] - public string? LogFile { get; set; } - - [CliOption(Name = "--log-console", Description = "Enable Package Deployer console logging.", Required = false)] - public bool LogConsole { get; set; } - - [CliOption(Name = "--connection-string", Description = "Dataverse connection string.", Required = false)] - public string? ConnectionString { get; set; } - - [CliOption(Name = "--environment", Description = "Dataverse environment URL for interactive sign-in.", Required = false)] - public string? EnvironmentUrl { get; set; } - - [CliOption(Name = "--device-code", Description = "Use device-code flow instead of browser interactive sign-in.", Required = false)] - public bool DeviceCode { get; set; } - - [CliOption(Name = "--verbose", Description = "Enable verbose logging.", Required = false)] - public bool Verbose { get; set; } - - public async Task RunAsync() - { - if (string.IsNullOrWhiteSpace(Package)) - { - _logger.LogError("'package' argument is required."); - return 1; - } - - bool isLocalFile = File.Exists(Package) - || Package.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) - || Package.EndsWith(".dll", StringComparison.OrdinalIgnoreCase); - - string packagePath; - string? tempWorkingDirectory = null; - string? nugetPackageName = null; - string? nugetPackageVersion = null; - - if (isLocalFile) - { - if (!File.Exists(Package)) - { - _logger.LogError("Package file not found: {PackagePath}", Package); - return 1; - } - - packagePath = Path.GetFullPath(Package); - _logger.LogInformation("Using local package: {PackagePath}", packagePath); - } - else - { - NuGetPackageInstallOptions options = new( - Package, - PackageVersion, - OutputDirectory); - - NuGetPackageInstallResult installResult = await _packageInstaller.InstallAsync(options); - - _logger.LogInformation("Resolved {PackageName} version {Version}", installResult.PackageName, installResult.ResolvedVersion); - _logger.LogInformation("Deployable package extracted to {Path}", installResult.DeployablePackagePath); - - nugetPackageName = installResult.PackageName; - nugetPackageVersion = installResult.ResolvedVersion; - - if (DownloadOnly) - { - return 0; - } - - packagePath = installResult.DeployablePackagePath; - - if (installResult.UsesTemporaryWorkingDirectory) - { - tempWorkingDirectory = installResult.WorkingDirectory; - } - } - - string? resolvedConnectionString = ServiceClientFactory.ResolveConnectionString(ConnectionString); - string? resolvedEnvironmentUrl = ServiceClientFactory.ResolveEnvironmentUrl(EnvironmentUrl); - PackageDeployerResult? deployResult = null; - string packageDeployerArtifactsDirectory = Path.Combine( - Path.GetTempPath(), - "txc", - "package-deployer-host", - Guid.NewGuid().ToString("N")); - - if (string.IsNullOrWhiteSpace(resolvedConnectionString) && string.IsNullOrWhiteSpace(resolvedEnvironmentUrl)) - { - _logger.LogError("Dataverse authentication is required to run Package Deployer. Pass --connection-string, pass --environment for interactive sign-in, or set DATAVERSE_CONNECTION_STRING / TXC_DATAVERSE_CONNECTION_STRING / DATAVERSE_ENVIRONMENT_URL / TXC_DATAVERSE_ENVIRONMENT_URL."); - _logger.LogError("Package located at {PackagePath}", packagePath); - return 1; - } - - try - { - deployResult = await PackageDeployerSubprocess.RunAsync(new PackageDeployerRequest( - packagePath, - resolvedConnectionString, - resolvedEnvironmentUrl, - DeviceCode, - Settings, - LogFile, - LogConsole, - Verbose, - packageDeployerArtifactsDirectory, - System.Environment.ProcessId, - NuGetPackageName: nugetPackageName, - NuGetPackageVersion: nugetPackageVersion)); - - if (!deployResult.Succeeded) - { - if (!string.IsNullOrWhiteSpace(deployResult.ErrorMessage)) - { - _logger.LogError("{ErrorMessage}", deployResult.ErrorMessage); - } - - if (!string.IsNullOrWhiteSpace(LogFile) && !string.IsNullOrWhiteSpace(deployResult.LogFilePath)) - { - _logger.LogError("Detailed Package Deployer log: {LogPath}", deployResult.LogFilePath); - } - - if (!string.IsNullOrWhiteSpace(LogFile) && !string.IsNullOrWhiteSpace(deployResult.CmtLogFilePath)) - { - _logger.LogError("Detailed CMT import log: {LogPath}", deployResult.CmtLogFilePath); - } - else if (string.IsNullOrWhiteSpace(LogFile) && - (!string.IsNullOrWhiteSpace(deployResult.LogFilePath) || !string.IsNullOrWhiteSpace(deployResult.CmtLogFilePath))) - { - _logger.LogWarning("Detailed temporary logs were cleaned up. Pass --log-file to preserve them."); - } - - _logger.LogError("Package import failed. Package located at {PackagePath}", packagePath); - return 1; - } - - _logger.LogInformation("Package import completed successfully."); - - if (!string.IsNullOrWhiteSpace(LogFile)) - { - _logger.LogInformation("Package Deployer log: {LogPath}", Path.GetFullPath(LogFile)); - } - - return 0; - } - catch (InvalidOperationException ex) - { - _logger.LogError(ex, "Package import failed"); - _logger.LogError("Package located at {PackagePath}", packagePath); - return 1; - } - finally - { - PackageDeployerSubprocess.TryDeleteDirectory(packageDeployerArtifactsDirectory); - - if (!string.IsNullOrWhiteSpace(tempWorkingDirectory)) - { - PackageDeployerSubprocess.TryDeleteDirectory(tempWorkingDirectory); - } - } - } -} diff --git a/src/TALXIS.CLI.Environment/Package/PackageUninstallCliCommand.cs b/src/TALXIS.CLI.Environment/Package/PackageUninstallCliCommand.cs deleted file mode 100644 index 08c07c1..0000000 --- a/src/TALXIS.CLI.Environment/Package/PackageUninstallCliCommand.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System.Text.Json; -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using Microsoft.PowerPlatform.Dataverse.Client; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment.Platforms.Dataverse; -using TALXIS.CLI.Logging; -using TALXIS.CLI.Shared; - -namespace TALXIS.CLI.Environment.Package; - -[CliCommand( - Name = "uninstall", - Description = "Uninstall all solutions belonging to a package from the target environment, in reverse import order." -)] -public class PackageUninstallCliCommand -{ - private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(PackageUninstallCliCommand)); - - [CliArgument(Name = "package", Description = "NuGet package name, local .pdpkg.zip/.pdpkg/.zip archive path, or extracted package folder path.", Required = true)] - public required string Package { get; set; } - - [CliOption(Name = "--version", Description = "NuGet package version when 'package' is a NuGet name. Defaults to 'latest'.", Required = false)] - public string PackageVersion { get; set; } = "latest"; - - [CliOption(Name = "--output", Aliases = ["-o"], Description = "Directory for temporary/downloaded package assets when resolving from NuGet.", Required = false)] - public string? OutputDirectory { get; set; } - - [CliOption(Name = "--yes", Description = "Confirm destructive uninstall actions.", Required = false)] - public bool Yes { get; set; } - - [CliOption(Name = "--connection-string", Description = "Dataverse connection string. If omitted, txc checks DATAVERSE_CONNECTION_STRING and TXC_DATAVERSE_CONNECTION_STRING.", Required = false)] - public string? ConnectionString { get; set; } - - [CliOption(Name = "--environment", Description = "Dataverse environment URL for interactive sign-in when no connection string is provided.", Required = false)] - public string? EnvironmentUrl { get; set; } - - [CliOption(Name = "--device-code", Description = "Use Microsoft Entra device code flow instead of opening a browser for interactive sign-in.", Required = false)] - public bool DeviceCode { get; set; } - - [CliOption(Name = "--json", Description = "Emit uninstall result as JSON.", Required = false)] - public bool Json { get; set; } - - [CliOption(Name = "--verbose", Description = "Enable verbose logging.", Required = false)] - public bool Verbose { get; set; } - - public async Task RunAsync() - { - if (!Yes) - { - _logger.LogError("Uninstall is destructive. Pass --yes to confirm."); - return 1; - } - - if (string.IsNullOrWhiteSpace(Package)) - { - _logger.LogError("'package' argument is required."); - return 1; - } - - DataverseConnection conn; - try - { - conn = ServiceClientFactory.Connect(ConnectionString, EnvironmentUrl, DeviceCode, Verbose, _logger); - } - catch (InvalidOperationException ex) - { - _logger.LogError("{Error}", ex.Message); - return 1; - } - - using (conn) - { - var client = conn.Client; - try - { - var uninstaller = new SolutionUninstaller(client, _logger); - return await RunPackageUninstallAsync(client, uninstaller).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "environment package uninstall failed"); - return 1; - } - } - } - - private async Task RunPackageUninstallAsync(ServiceClient client, SolutionUninstaller uninstaller) - { - var sourceReader = new PackageImportConfigReader(); - var importOrder = await sourceReader.ReadSolutionUniqueNamesInImportOrderAsync( - Package, - PackageVersion, - OutputDirectory) - .ConfigureAwait(false); - - var solutionNames = BuildReverseUninstallOrderFromImportConfig(importOrder); - if (solutionNames.Count == 0) - { - _logger.LogError("No uninstallable solutions were resolved from package '{Source}'.", Package); - return 1; - } - - var packageDisplayName = InferPackageDisplayNameFromSource(Package); - var outcomes = await ExecutePackageUninstallAsync( - client, - uninstaller, - solutionNames, - packageDisplayName, - packageRunLabel: Package) - .ConfigureAwait(false); - - if (Json) - { - OutputWriter.WriteLine(JsonSerializer.Serialize(new - { - mode = "package", - package = Package, - packageName = packageDisplayName, - solutionCount = solutionNames.Count, - uninstallOrder = solutionNames, - outcomes, - }, JsonOptions)); - } - else - { - OutputWriter.WriteLine($"Package: {packageDisplayName}"); - OutputWriter.WriteLine($"Source: {Package}"); - OutputWriter.WriteLine($"Resolved solutions: {solutionNames.Count}"); - OutputWriter.WriteLine("Uninstall order (reverse ImportConfig):"); - foreach (var name in solutionNames) - { - OutputWriter.WriteLine($" - {name}"); - } - foreach (var outcome in outcomes) - { - OutputWriter.WriteLine($"- {outcome.SolutionName}: {outcome.Status} ({outcome.Message})"); - } - } - - return outcomes.All(o => o.Status == SolutionUninstallStatus.Success) ? 0 : 1; - } - - private async Task> ExecutePackageUninstallAsync( - ServiceClient? client, - SolutionUninstaller uninstaller, - IReadOnlyList solutionNames, - string? packageDisplayName, - string packageRunLabel) - { - Guid? historyId = null; - int? successState = null; - int? successStatus = null; - int? failedState = null; - int? failedStatus = null; - if (client is not null) - { - var historyWriter = new PackageHistoryWriter(client, _logger); - var statusCodes = await historyWriter.ResolveStatusCodesAsync().ConfigureAwait(false); - successState = statusCodes.SuccessState; - successStatus = statusCodes.SuccessStatus; - failedState = statusCodes.FailedState; - failedStatus = statusCodes.FailedStatus; - - var created = await historyWriter.TryCreateUninstallRunAsync( - uniqueName: packageDisplayName ?? packageRunLabel, - executionName: $"txc uninstall {packageRunLabel}", - statusCode: statusCodes.InProcessStatus, - message: $"Package uninstall started. {solutionNames.Count} solution(s) in reverse order.") - .ConfigureAwait(false); - historyId = created?.Id; - } - - var outcomes = new List(solutionNames.Count); - for (int i = 0; i < solutionNames.Count; i++) - { - var name = solutionNames[i]; - _logger.LogInformation("[{Current}/{Total}] Uninstalling solution {SolutionName}...", i + 1, solutionNames.Count, name); - var outcome = await uninstaller.UninstallByUniqueNameAsync(name).ConfigureAwait(false); - outcomes.Add(outcome); - - if (outcome.Status == SolutionUninstallStatus.Success) - { - _logger.LogInformation("[{Current}/{Total}] {SolutionName}: {Status}", i + 1, solutionNames.Count, outcome.SolutionName, outcome.Status); - } - else - { - _logger.LogWarning("[{Current}/{Total}] {SolutionName}: {Status} ({Message})", i + 1, solutionNames.Count, outcome.SolutionName, outcome.Status, outcome.Message); - } - } - - if (client is not null && historyId is { } id) - { - var historyWriter = new PackageHistoryWriter(client, _logger); - bool allSuccess = outcomes.All(o => o.Status == SolutionUninstallStatus.Success); - await historyWriter.TryUpdateStatusAsync( - id, - allSuccess ? successState : failedState, - allSuccess ? successStatus : failedStatus, - allSuccess - ? $"Package uninstall completed. {outcomes.Count} solution(s) uninstalled." - : $"Package uninstall completed with failures. {outcomes.Count(o => o.Status == SolutionUninstallStatus.Success)}/{outcomes.Count} succeeded.") - .ConfigureAwait(false); - } - - return outcomes; - } - - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true, - }; - - public static IReadOnlyList BuildReverseUninstallOrderFromImportConfig(IReadOnlyList importOrderSolutionNames) - { - ArgumentNullException.ThrowIfNull(importOrderSolutionNames); - - var ordered = importOrderSolutionNames - .Where(n => !string.IsNullOrWhiteSpace(n)) - .Select(n => n.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - ordered.Reverse(); - return ordered; - } - - private static string InferPackageDisplayNameFromSource(string packageSource) - { - if (string.IsNullOrWhiteSpace(packageSource)) - { - return "(unknown)"; - } - - if (Directory.Exists(packageSource) || File.Exists(packageSource)) - { - var name = Path.GetFileName(packageSource.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - if (name.EndsWith(".pdpkg.zip", StringComparison.OrdinalIgnoreCase)) - { - return name[..^".pdpkg.zip".Length]; - } - - if (name.EndsWith(".pdpkg", StringComparison.OrdinalIgnoreCase)) - { - return name[..^".pdpkg".Length]; - } - - return string.IsNullOrWhiteSpace(name) ? "(unknown)" : name; - } - - return packageSource.Trim(); - } -} diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageDeployerSubprocess.cs b/src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageDeployerSubprocess.cs deleted file mode 100644 index 338edfa..0000000 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageDeployerSubprocess.cs +++ /dev/null @@ -1,370 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.Reflection; -using System.Text.Json; -using TALXIS.CLI.XrmTools; - -namespace TALXIS.CLI.Environment.Platforms.Dataverse; - -public static class PackageDeployerSubprocess -{ - private const string CommandName = "__txc_internal_package_deployer"; - private const string CleanupCommandName = "__txc_internal_package_deployer_cleanup"; - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - public static async Task TryRunAsync(string[] args) - { - if (args.Length > 0 && string.Equals(args[0], CleanupCommandName, StringComparison.Ordinal)) - { - return await RunCleanupHelperAsync(args); - } - - if (args.Length != 3 || !string.Equals(args[0], CommandName, StringComparison.Ordinal)) - { - return null; - } - - string requestPath = args[1]; - string resultPath = args[2]; - - PackageDeployerResult result; - try - { - PackageDeployerRequest request = await ReadJsonAsync(requestPath); - using CancellationTokenSource parentWatcher = RegisterParentExitWatcher(request.ParentProcessId); - try - { - PackageDeployerRunner runner = new(); - result = await runner.RunAsync(request); - } - finally - { - parentWatcher.Cancel(); - } - } - catch (Exception ex) - { - result = new PackageDeployerResult(false, ex.Message, null, null, null); - } - - await WriteJsonAsync(resultPath, result); - return result.Succeeded ? 0 : 1; - } - - public static async Task RunAsync(PackageDeployerRequest request, CancellationToken cancellationToken = default) - { - string coordinatorDirectory = Path.Combine( - Path.GetTempPath(), - "txc", - "package-deployer-process", - Guid.NewGuid().ToString("N")); - string temporaryArtifactsDirectory = Path.Combine( - Path.GetTempPath(), - "txc", - "package-deployer-host", - Guid.NewGuid().ToString("N")); - PackageDeployerRequest effectiveRequest = request with - { - TemporaryArtifactsDirectory = temporaryArtifactsDirectory, - ParentProcessId = System.Environment.ProcessId - }; - - Directory.CreateDirectory(coordinatorDirectory); - - string requestPath = Path.Combine(coordinatorDirectory, "request.json"); - string resultPath = Path.Combine(coordinatorDirectory, "result.json"); - - try - { - await WriteJsonAsync(requestPath, effectiveRequest); - - using Process process = StartSubprocess(requestPath, resultPath); - using Process cleanupHelper = StartCleanupHelper(coordinatorDirectory, temporaryArtifactsDirectory, process.Id); - try - { - await process.WaitForExitAsync(cancellationToken); - } - catch (OperationCanceledException) - { - TryKillProcessTree(process); - TryKillProcess(cleanupHelper); - await WaitForExitIgnoringErrorsAsync(process); - await WaitForExitIgnoringErrorsAsync(cleanupHelper); - throw; - } - - if (!File.Exists(resultPath)) - { - throw new InvalidOperationException("Package Deployer subprocess did not produce a result."); - } - - return await ReadJsonAsync(resultPath); - } - finally - { - TryDeleteDirectory(temporaryArtifactsDirectory); - TryDeleteDirectory(coordinatorDirectory); - } - } - - internal static void TryDeleteDirectory(string path) - { - const int maxAttempts = 5; - - for (int attempt = 1; attempt <= maxAttempts; attempt++) - { - try - { - if (!Directory.Exists(path)) - { - return; - } - - foreach (string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) - { - File.SetAttributes(file, FileAttributes.Normal); - } - - Directory.Delete(path, recursive: true); - TryDeleteEmptyParentDirectories(path); - return; - } - catch (DirectoryNotFoundException) - { - return; - } - catch (IOException) when (attempt < maxAttempts) - { - Thread.Sleep(TimeSpan.FromMilliseconds(250 * attempt)); - } - catch (UnauthorizedAccessException) when (attempt < maxAttempts) - { - Thread.Sleep(TimeSpan.FromMilliseconds(250 * attempt)); - } - } - } - - private static void TryDeleteEmptyParentDirectories(string deletedPath) - { - string stopPath = Path.Combine(Path.GetTempPath(), "txc"); - DirectoryInfo? current = Directory.GetParent(deletedPath); - - while (current is not null && - current.Exists && - !string.Equals(current.FullName, stopPath, StringComparison.OrdinalIgnoreCase)) - { - if (current.EnumerateFileSystemInfos().Any()) - { - return; - } - - try - { - Directory.Delete(current.FullName, recursive: false); - } - catch - { - return; - } - - current = current.Parent; - } - } - - private static Process StartSubprocess(string requestPath, string resultPath) - { - string processPath = System.Environment.ProcessPath - ?? throw new InvalidOperationException("Could not resolve the current txc process path."); - - string? entryAssemblyPath = Assembly.GetEntryAssembly()?.Location; - ProcessStartInfo startInfo = new() - { - UseShellExecute = false, - WorkingDirectory = System.Environment.CurrentDirectory - }; - - if (IsDotnetHost(processPath)) - { - if (string.IsNullOrWhiteSpace(entryAssemblyPath)) - { - throw new InvalidOperationException("Could not resolve the txc entry assembly path."); - } - - startInfo.FileName = processPath; - startInfo.ArgumentList.Add(entryAssemblyPath); - } - else - { - startInfo.FileName = processPath; - } - - startInfo.ArgumentList.Add(CommandName); - startInfo.ArgumentList.Add(requestPath); - startInfo.ArgumentList.Add(resultPath); - - return Process.Start(startInfo) - ?? throw new InvalidOperationException("Failed to start the Package Deployer subprocess."); - } - - private static Process StartCleanupHelper(string coordinatorDirectory, string temporaryArtifactsDirectory, int childProcessId) - { - string processPath = System.Environment.ProcessPath - ?? throw new InvalidOperationException("Could not resolve the current txc process path."); - - string? entryAssemblyPath = Assembly.GetEntryAssembly()?.Location; - ProcessStartInfo startInfo = new() - { - UseShellExecute = false, - WorkingDirectory = System.Environment.CurrentDirectory, - CreateNoWindow = true - }; - - if (IsDotnetHost(processPath)) - { - if (string.IsNullOrWhiteSpace(entryAssemblyPath)) - { - throw new InvalidOperationException("Could not resolve the txc entry assembly path."); - } - - startInfo.FileName = processPath; - startInfo.ArgumentList.Add(entryAssemblyPath); - } - else - { - startInfo.FileName = processPath; - } - - startInfo.ArgumentList.Add(CleanupCommandName); - startInfo.ArgumentList.Add(coordinatorDirectory); - startInfo.ArgumentList.Add(temporaryArtifactsDirectory); - startInfo.ArgumentList.Add(childProcessId.ToString(CultureInfo.InvariantCulture)); - startInfo.ArgumentList.Add(System.Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); - - return Process.Start(startInfo) - ?? throw new InvalidOperationException("Failed to start the Package Deployer cleanup helper."); - } - - private static bool IsDotnetHost(string processPath) - { - string fileName = Path.GetFileNameWithoutExtension(processPath); - return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase); - } - - private static void TryKillProcessTree(Process process) - { - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - } - } - catch (InvalidOperationException) - { - } - catch (NotSupportedException) - { - TryKillProcess(process); - } - } - - private static void TryKillProcess(Process process) - { - try - { - if (!process.HasExited) - { - process.Kill(); - } - } - catch (InvalidOperationException) - { - } - } - - private static async Task WaitForExitIgnoringErrorsAsync(Process process) - { - try - { - await process.WaitForExitAsync(); - } - catch (InvalidOperationException) - { - } - } - - private static async Task RunCleanupHelperAsync(string[] args) - { - if (args.Length != 5) - { - return 1; - } - - string coordinatorDirectory = args[1]; - string temporaryArtifactsDirectory = args[2]; - int childProcessId = int.Parse(args[3], CultureInfo.InvariantCulture); - int parentProcessId = int.Parse(args[4], CultureInfo.InvariantCulture); - - await WaitForProcessExitAsync(childProcessId); - TryDeleteDirectory(temporaryArtifactsDirectory); - - await WaitForProcessExitAsync(parentProcessId); - TryDeleteDirectory(coordinatorDirectory); - TryDeleteDirectory(temporaryArtifactsDirectory); - return 0; - } - - private static CancellationTokenSource RegisterParentExitWatcher(int parentProcessId) - { - CancellationTokenSource cts = new(); - if (parentProcessId <= 0) - { - return cts; - } - - _ = Task.Run(async () => - { - await WaitForProcessExitAsync(parentProcessId); - if (!cts.IsCancellationRequested) - { - System.Environment.FailFast("Parent txc process exited while Package Deployer subprocess was still running."); - } - }); - - return cts; - } - - private static async Task WaitForProcessExitAsync(int processId) - { - if (processId <= 0) - { - return; - } - - try - { - using Process process = Process.GetProcessById(processId); - await process.WaitForExitAsync(); - } - catch (ArgumentException) - { - } - catch (InvalidOperationException) - { - } - } - - private static async Task ReadJsonAsync(string path) - { - await using FileStream stream = File.OpenRead(path); - T? value = await JsonSerializer.DeserializeAsync(stream, JsonOptions); - return value ?? throw new InvalidOperationException($"Could not deserialize '{path}'."); - } - - private static async Task WriteJsonAsync(string path, T value) - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - await using FileStream stream = File.Create(path); - await JsonSerializer.SerializeAsync(stream, value, JsonOptions); - } -} diff --git a/src/TALXIS.CLI.Environment/Solution/SolutionImportCliCommand.cs b/src/TALXIS.CLI.Environment/Solution/SolutionImportCliCommand.cs deleted file mode 100644 index 852a233..0000000 --- a/src/TALXIS.CLI.Environment/Solution/SolutionImportCliCommand.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System.ComponentModel; -using System.Text.Json; -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment.Platforms.Dataverse; -using TALXIS.CLI.Logging; -using TALXIS.CLI.Shared; - -namespace TALXIS.CLI.Environment.Solution; - -[CliCommand( - Name = "import", - Description = "Import a solution .zip into the target environment." -)] -public class SolutionImportCliCommand -{ - private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(SolutionImportCliCommand)); - - [CliArgument(Name = "solution-zip", Description = "Path to the solution .zip to import.", Required = true)] - public required string SolutionZip { get; set; } - - [CliOption(Name = "--stage-and-upgrade", Description = "Use single-step upgrade when applicable.", Required = false)] - [DefaultValue(true)] - public bool StageAndUpgrade { get; set; } = true; - - [CliOption(Name = "--force-overwrite", Description = "Overwrite unmanaged customizations (disables SmartDiff).", Required = false)] - public bool ForceOverwrite { get; set; } - - [CliOption(Name = "--publish-workflows", Description = "Activate workflows after import.", Required = false)] - public bool PublishWorkflows { get; set; } - - [CliOption(Name = "--skip-dependency-check", Description = "Skip product-update dependency checks.", Required = false)] - public bool SkipDependencyCheck { get; set; } - - [CliOption(Name = "--skip-lower-version", Description = "Skip import when source version is not higher than target.", Required = false)] - public bool SkipLowerVersion { get; set; } - - [CliOption(Name = "--wait", Description = "Wait for completion. By default solution imports return after queueing.", Required = false)] - public bool Wait { get; set; } - - [CliOption(Name = "--json", Description = "Emit import result as JSON.", Required = false)] - public bool Json { get; set; } - - [CliOption(Name = "--connection-string", Description = "Dataverse connection string.", Required = false)] - public string? ConnectionString { get; set; } - - [CliOption(Name = "--environment", Description = "Dataverse environment URL for interactive sign-in.", Required = false)] - public string? EnvironmentUrl { get; set; } - - [CliOption(Name = "--device-code", Description = "Use device-code flow instead of browser interactive sign-in.", Required = false)] - public bool DeviceCode { get; set; } - - [CliOption(Name = "--verbose", Description = "Enable verbose logging.", Required = false)] - public bool Verbose { get; set; } - - public async Task RunAsync() - { - if (string.IsNullOrWhiteSpace(SolutionZip)) - { - _logger.LogError("'solution-zip' argument is required."); - return 1; - } - - string solutionPath = Path.GetFullPath(SolutionZip); - if (!File.Exists(solutionPath)) - { - _logger.LogError("Solution file not found: {Path}", solutionPath); - return 1; - } - - string? resolvedConnectionString = ServiceClientFactory.ResolveConnectionString(ConnectionString); - string? resolvedEnvironmentUrl = ServiceClientFactory.ResolveEnvironmentUrl(EnvironmentUrl); - - if (string.IsNullOrWhiteSpace(resolvedConnectionString) && string.IsNullOrWhiteSpace(resolvedEnvironmentUrl)) - { - _logger.LogError("Dataverse authentication is required. Pass --connection-string, pass --environment for interactive sign-in, or set DATAVERSE_CONNECTION_STRING / TXC_DATAVERSE_CONNECTION_STRING / DATAVERSE_ENVIRONMENT_URL / TXC_DATAVERSE_ENVIRONMENT_URL."); - return 1; - } - - SolutionInfo source; - try - { - source = SolutionImporter.ReadSolutionInfo(solutionPath); - } - catch (Exception ex) when (ex is InvalidOperationException or FileNotFoundException) - { - _logger.LogError(ex, "Unable to read solution metadata from {Path}", solutionPath); - return 1; - } - - _logger.LogInformation("Source solution: {UniqueName} {Version} ({Managed})", - source.UniqueName, source.Version, source.Managed ? "managed" : "unmanaged"); - - DataverseConnection conn; - try - { - conn = ServiceClientFactory.Connect(ConnectionString, EnvironmentUrl, DeviceCode, Verbose, _logger); - } - catch (InvalidOperationException ex) - { - _logger.LogError("{Error}", ex.Message); - return 1; - } - - using (conn) - { - var client = conn.Client; - try - { - var importer = new SolutionImporter(client, _logger); - var existing = await importer.GetExistingSolutionAsync(source.UniqueName).ConfigureAwait(false); - var plannedPath = SolutionImporter.SelectImportPath(source, existing, StageAndUpgrade); - bool smartDiffExpected = SolutionImporter.SmartDiffExpected(plannedPath, ForceOverwrite); - - _logger.LogInformation("Planned import path: {Path}", FormatPath(plannedPath)); - _logger.LogInformation("SmartDiff expected: {SmartDiff}", smartDiffExpected ? "yes" : "no"); - - EmitWarnings(plannedPath, ForceOverwrite); - - var options = new SolutionImportOptions( - StageAndUpgrade: StageAndUpgrade, - ForceOverwrite: ForceOverwrite, - PublishWorkflows: PublishWorkflows, - SkipDependencyCheck: SkipDependencyCheck, - SkipLowerVersion: SkipLowerVersion, - Async: !Wait); - - var result = await importer.ImportAsync(solutionPath, options).ConfigureAwait(false); - - if (Json) - { - var payload = new - { - path = FormatPath(result.Path), - uniqueName = result.Source.UniqueName, - sourceVersion = result.Source.Version.ToString(), - sourceManaged = result.Source.Managed, - existingVersion = result.ExistingTarget?.Version.ToString(), - existingManaged = result.ExistingTarget?.Managed, - importJobId = result.ImportJobId, - asyncOperationId = result.AsyncOperationId, - startedAtUtc = result.StartedAtUtc.ToString("O"), - completedAtUtc = result.CompletedAtUtc?.ToString("O"), - smartDiffExpected = result.SmartDiffExpected, - status = result.Status, - }; - OutputWriter.WriteLine(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); - } - - _logger.LogInformation("Import path: {Path}", FormatPath(result.Path)); - _logger.LogInformation("Status: {Status}", result.Status); - _logger.LogInformation("ImportJobId: {ImportJobId}", result.ImportJobId); - if (result.AsyncOperationId is { } asyncId) - { - _logger.LogInformation("AsyncOperationId: {AsyncOperationId}", asyncId); - } - _logger.LogInformation("Started (UTC): {Start}", result.StartedAtUtc.ToString("O")); - if (result.CompletedAtUtc is { } completed) - { - _logger.LogInformation("Completed (UTC): {End}", completed.ToString("O")); - } - - return 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "Solution import failed"); - return 1; - } - } - } - - private void EmitWarnings(SolutionImportPath plannedPath, bool forceOverwrite) - { - if (forceOverwrite && plannedPath == SolutionImportPath.Upgrade) - { - _logger.LogWarning("--force-overwrite disables SmartDiff; expect a full re-import."); - } - - if (plannedPath == SolutionImportPath.Update) - { - _logger.LogWarning("Plain update does not delete components removed from the source solution."); - } - } - - private static string FormatPath(SolutionImportPath path) => path switch - { - SolutionImportPath.Install => "install", - SolutionImportPath.Update => "update", - SolutionImportPath.Upgrade => "single-step upgrade", - _ => path.ToString() - }; -} diff --git a/src/TALXIS.CLI.Features.Config/Abstractions/ProfiledCliCommand.cs b/src/TALXIS.CLI.Features.Config/Abstractions/ProfiledCliCommand.cs new file mode 100644 index 0000000..ee2ba74 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Abstractions/ProfiledCliCommand.cs @@ -0,0 +1,38 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Config.Abstractions; + +/// +/// Base class for every leaf command that needs a resolved (Profile, +/// Connection, Credential) triple before it can run. Exposes exactly two +/// CLI options — --profile (with its deliberate -p short +/// alias, the only flag-level short in the CLI) and --verbose. +/// Everything else (endpoint URLs, credential material, device-code +/// toggles, env-var fallbacks) is resolved behind +/// IConfigurationResolver; leaf commands never parse raw auth +/// flags of their own. +/// +/// +/// The options are declared as direct properties (not a nested options +/// record) so the MCP adapter — which reflects [CliOption] +/// properties on the command type — surfaces them automatically on every +/// derived command. The consistent two-flag surface is the whole point +/// of this refactor: agent prompts only have to know "pass +/// --profile <x> if you want a specific target" and every +/// command behaves identically. +/// +public abstract class ProfiledCliCommand +{ + [CliOption( + Name = "--profile", + Aliases = new[] { "-p" }, + Description = "Profile name to resolve (falls back to TXC_PROFILE, workspace pin, or global active).", + Required = false)] + public string? Profile { get; set; } + + [CliOption( + Name = "--verbose", + Description = "Emit verbose logging for this invocation.", + Required = false)] + public bool Verbose { get; set; } +} diff --git a/src/TALXIS.CLI.Features.Config/Auth/AuthAddServicePrincipalCliCommand.cs b/src/TALXIS.CLI.Features.Config/Auth/AuthAddServicePrincipalCliCommand.cs new file mode 100644 index 0000000..7249fdb --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Auth/AuthAddServicePrincipalCliCommand.cs @@ -0,0 +1,187 @@ +using System.Text; +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Auth; + +/// +/// txc config auth add-service-principal — register a client-secret +/// Entra app registration as a reusable . The +/// secret value is read via one of (in order): +/// --secret-from-env → piped stdin → masked TTY prompt. +/// It is then written to the OS credential vault; only a +/// handle is persisted in the credential store. +/// +[CliCommand( + Name = "add-service-principal", + Aliases = new[] { "add-sp" }, + Description = "Register a client-secret service principal credential." +)] +public class AuthAddServicePrincipalCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(AuthAddServicePrincipalCliCommand)); + + [CliOption(Name = "--alias", Description = "Credential alias used to reference this service principal.", Required = true)] + public string Alias { get; set; } = string.Empty; + + [CliOption(Name = "--tenant", Description = "Entra tenant id or domain.", Required = true)] + public string Tenant { get; set; } = string.Empty; + + [CliOption(Name = "--application-id", Aliases = new[] { "--app-id", "--client-id" }, Description = "Entra application (client) id.", Required = true)] + public string ApplicationId { get; set; } = string.Empty; + + [CliOption(Name = "--cloud", Description = "Sovereign cloud. Default: public.", Required = false)] + public CloudInstance? Cloud { get; set; } + + [CliOption(Name = "--description", Description = "Free-form label shown in 'config auth list'.", Required = false)] + public string? Description { get; set; } + + [CliOption(Name = "--secret-from-env", Description = "Name of an environment variable holding the client secret.", Required = false)] + public string? SecretFromEnv { get; set; } + + public async Task RunAsync() + { + try + { + var headless = TxcServices.Get(); + headless.EnsureKindAllowed(CredentialKind.ClientSecret); + + var alias = Alias.Trim(); + if (string.IsNullOrEmpty(alias)) + { + _logger.LogError("--alias must not be empty."); + return 1; + } + + var secret = ReadSecret(SecretFromEnv, _logger); + if (secret is null) return 1; + + var store = TxcServices.Get(); + var vault = TxcServices.Get(); + + var secretRef = SecretRef.Create(alias, "client-secret"); + await vault.SetSecretAsync(secretRef, secret, CancellationToken.None).ConfigureAwait(false); + + var credential = new Credential + { + Id = alias, + Kind = CredentialKind.ClientSecret, + TenantId = Tenant.Trim(), + ApplicationId = ApplicationId.Trim(), + Cloud = Cloud ?? CloudInstance.Public, + Description = Description, + SecretRef = secretRef, + }; + await store.UpsertAsync(credential, CancellationToken.None).ConfigureAwait(false); + + _logger.LogInformation("Saved service-principal credential '{Alias}' (app {AppId}, tenant {Tenant}).", + alias, credential.ApplicationId, credential.TenantId); + + OutputWriter.WriteLine(JsonSerializer.Serialize( + new + { + id = credential.Id, + kind = credential.Kind, + tenantId = credential.TenantId, + applicationId = credential.ApplicationId, + cloud = credential.Cloud, + description = credential.Description, + }, + TxcJsonOptions.Default)); + return 0; + } + catch (HeadlessAuthRequiredException ex) + { + _logger.LogError("{Message}", ex.Message); + return 1; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to register service-principal credential."); + return 1; + } + } + + /// + /// Test seam: when non-null, treats this as + /// a redirected stdin and reads its first line instead of consulting + /// / . + /// Production callers leave this null. + /// + internal static TextReader? StdinOverride { get; set; } + + /// + /// Resolves the client secret from (in order): the named env var, + /// redirected stdin, or an interactive masked TTY prompt. Returns + /// null and logs an error when no source is available. Internal + /// for testability. + /// + internal static string? ReadSecret(string? secretFromEnv, ILogger logger) + { + if (!string.IsNullOrWhiteSpace(secretFromEnv)) + { + var value = System.Environment.GetEnvironmentVariable(secretFromEnv); + if (string.IsNullOrEmpty(value)) + { + logger.LogError("Environment variable '{Var}' is not set or empty.", secretFromEnv); + return null; + } + return value; + } + + var stdin = StdinOverride; + if (stdin is not null || Console.IsInputRedirected) + { + var reader = stdin ?? Console.In; + var piped = reader.ReadLine(); + if (string.IsNullOrEmpty(piped)) + { + logger.LogError("Stdin was redirected but no secret was read. Pipe the secret value or pass --secret-from-env."); + return null; + } + return piped; + } + + if (!Console.IsOutputRedirected) + { + return PromptMaskedSecret(); + } + + logger.LogError( + "No client secret provided. Use --secret-from-env , pipe the secret via stdin, or run in an interactive terminal."); + return null; + } + + private static string PromptMaskedSecret() + { + Console.Write("Client secret: "); + var buffer = new StringBuilder(); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + break; + } + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Length > 0) buffer.Length--; + continue; + } + if (!char.IsControl(key.KeyChar)) + { + buffer.Append(key.KeyChar); + } + } + return buffer.ToString(); + } +} diff --git a/src/TALXIS.CLI.Features.Config/Auth/AuthCliCommand.cs b/src/TALXIS.CLI.Features.Config/Auth/AuthCliCommand.cs new file mode 100644 index 0000000..12d8f4e --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Auth/AuthCliCommand.cs @@ -0,0 +1,27 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Config.Auth; + +/// +/// txc config auth — purpose-built credential verbs. Each kind +/// has its own verb (login / add-service-principal / add-federated) +/// instead of a shared create --kind surface: the options each +/// kind needs differ enough that purpose-built verbs stay simpler and +/// easier to document. +/// +[CliCommand( + Name = "auth", + Description = "Manage Entra / Dataverse credentials stored in the OS vault.", + Children = new[] + { + typeof(AuthLoginCliCommand), + typeof(AuthAddServicePrincipalCliCommand), + typeof(AuthListCliCommand), + typeof(AuthShowCliCommand), + typeof(AuthDeleteCliCommand), + } +)] +public class AuthCliCommand +{ + public void Run(CliContext context) => context.ShowHelp(); +} diff --git a/src/TALXIS.CLI.Features.Config/Auth/AuthDeleteCliCommand.cs b/src/TALXIS.CLI.Features.Config/Auth/AuthDeleteCliCommand.cs new file mode 100644 index 0000000..c4d5743 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Auth/AuthDeleteCliCommand.cs @@ -0,0 +1,98 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Auth; + +/// +/// txc config auth delete <alias> — removes the credential +/// entry from the store and deletes any associated secret from the OS +/// vault. +/// +/// Profiles that reference the deleted credential are left in place +/// but orphaned; the command warns about each one. pac CLI's +/// pac auth clear behaves similarly; cascading deletes would be +/// surprising and are therefore intentionally not performed. +/// +[McpIgnore] +[CliCommand( + Name = "delete", + Description = "Delete a stored credential. Profiles referencing it are left orphaned with a warning." +)] +public class AuthDeleteCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(AuthDeleteCliCommand)); + + [CliArgument(Description = "Credential alias (id) to delete.")] + public required string Alias { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Alias)) + { + _logger.LogError("Credential alias must be provided."); + return 1; + } + + try + { + var credStore = TxcServices.Get(); + var profileStore = TxcServices.Get(); + var vault = TxcServices.Get(); + + var existing = await credStore.GetAsync(Alias, CancellationToken.None).ConfigureAwait(false); + if (existing is null) + { + _logger.LogError("Credential '{Alias}' not found.", Alias); + return 2; + } + + // Surface any profile that would be orphaned. Iterate profiles first + // so the user sees the warning even if the vault delete below fails. + var profiles = await profileStore.ListAsync(CancellationToken.None).ConfigureAwait(false); + foreach (var p in profiles.Where(p => + string.Equals(p.CredentialRef, Alias, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogWarning( + "Profile '{ProfileId}' references credential '{Alias}' and will be orphaned. " + + "Update or delete the profile explicitly.", + p.Id, Alias); + } + + // Best-effort secret purge — absent secret (interactive / WIF) is fine. + if (existing.SecretRef is { } secretRef) + { + try + { + await vault.DeleteSecretAsync(secretRef, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Credential '{Alias}' secret could not be removed from the vault. " + + "You may need to delete it manually.", + Alias); + } + } + + var removed = await credStore.DeleteAsync(Alias, CancellationToken.None).ConfigureAwait(false); + if (!removed) + { + _logger.LogError("Credential '{Alias}' disappeared during delete.", Alias); + return 1; + } + + _logger.LogInformation("Credential '{Alias}' deleted.", Alias); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete credential '{Alias}'.", Alias); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Auth/AuthListCliCommand.cs b/src/TALXIS.CLI.Features.Config/Auth/AuthListCliCommand.cs new file mode 100644 index 0000000..37dad32 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Auth/AuthListCliCommand.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Auth; + +/// +/// txc config auth list — dumps all stored credentials as a JSON +/// array on stdout. Secrets are held in the OS vault and are never +/// touched by this command. +/// +[CliCommand( + Name = "list", + Description = "List all stored credentials as JSON." +)] +public class AuthListCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(AuthListCliCommand)); + + public async Task RunAsync() + { + try + { + var store = TxcServices.Get(); + IReadOnlyList creds = await store.ListAsync(CancellationToken.None).ConfigureAwait(false); + + // Project to a deterministic shape: id, kind, tenantId, applicationId, cloud, description. + // SecretRef is implied by kind — the secret itself never leaves the vault. + var projected = creds.Select(c => new + { + id = c.Id, + kind = c.Kind, + tenantId = c.TenantId, + applicationId = c.ApplicationId, + cloud = c.Cloud, + description = c.Description, + }); + + var json = JsonSerializer.Serialize(projected, TxcJsonOptions.Default); + OutputWriter.WriteLine(json); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to list credentials."); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Auth/AuthLoginCliCommand.cs b/src/TALXIS.CLI.Features.Config/Auth/AuthLoginCliCommand.cs new file mode 100644 index 0000000..ffdddf2 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Auth/AuthLoginCliCommand.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Bootstrapping; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Auth; + +/// +/// txc config auth login — eager interactive browser sign-in. +/// Persists an +/// credential whose refresh token sits in the shared MSAL cache; no +/// secret material is written to the txc credential-vault file. +/// +/// +/// Fails fast with exit 1 in headless contexts — interactive browser is +/// never a permitted headless kind. See . +/// +[McpIgnore] +[CliCommand( + Name = "login", + Description = "Interactive browser sign-in. Persists a credential named after the UPN (override with --alias)." +)] +public class AuthLoginCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(AuthLoginCliCommand)); + + [CliOption(Name = "--tenant", Description = "Entra tenant id or domain. When omitted, the user picks an org in the browser.", Required = false)] + public string? Tenant { get; set; } + + [CliOption(Name = "--alias", Description = "Credential alias. Default: signed-in UPN (collision-resolved).", Required = false)] + public string? Alias { get; set; } + + [CliOption(Name = "--cloud", Description = "Sovereign cloud. Default: public.", Required = false)] + public CloudInstance? Cloud { get; set; } + + public async Task RunAsync() + { + try + { + var login = TxcServices.Get(); + var store = TxcServices.Get(); + var headless = TxcServices.Get(); + var cloud = Cloud ?? CloudInstance.Public; + + _logger.LogInformation("Starting interactive sign-in..."); + var result = await InteractiveCredentialBootstrapper.AcquireAndPersistAsync( + login, store, headless, Tenant, cloud, Alias, CancellationToken.None).ConfigureAwait(false); + + _logger.LogInformation("Signed in as {Upn} (tenant {Tenant}). Credential '{Alias}' saved.", + result.Upn, result.TenantId, result.Credential.Id); + + OutputWriter.WriteLine(JsonSerializer.Serialize( + new { id = result.Credential.Id, upn = result.Upn, tenantId = result.TenantId, cloud }, + TxcJsonOptions.Default)); + return 0; + } + catch (HeadlessAuthRequiredException ex) + { + _logger.LogError("{Message}", ex.Message); + return 1; + } + catch (OperationCanceledException) + { + _logger.LogWarning("Interactive sign-in was cancelled."); + return 1; + } + catch (Exception ex) + { + _logger.LogError(ex, "Interactive sign-in failed."); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Auth/AuthShowCliCommand.cs b/src/TALXIS.CLI.Features.Config/Auth/AuthShowCliCommand.cs new file mode 100644 index 0000000..59adb6f --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Auth/AuthShowCliCommand.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Auth; + +/// +/// txc config auth show <alias> — prints one credential's +/// non-secret fields as JSON. Exit code 2 if the alias is not found so +/// scripts can distinguish "missing" from "internal error" (1). +/// +[CliCommand( + Name = "show", + Description = "Show a stored credential's non-secret fields as JSON." +)] +public class AuthShowCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(AuthShowCliCommand)); + + [CliArgument(Description = "Credential alias (id).")] + public required string Alias { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Alias)) + { + _logger.LogError("Credential alias must be provided."); + return 1; + } + + try + { + var store = TxcServices.Get(); + var cred = await store.GetAsync(Alias, CancellationToken.None).ConfigureAwait(false); + if (cred is null) + { + _logger.LogError("Credential '{Alias}' not found.", Alias); + return 2; + } + + var projected = new + { + id = cred.Id, + kind = cred.Kind, + tenantId = cred.TenantId, + applicationId = cred.ApplicationId, + cloud = cred.Cloud, + description = cred.Description, + certificatePath = cred.CertificatePath, + secretRef = cred.SecretRef?.Uri, + }; + + OutputWriter.WriteLine(JsonSerializer.Serialize(projected, TxcJsonOptions.Default)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to show credential '{Alias}'.", Alias); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/ConfigCliCommand.cs b/src/TALXIS.CLI.Features.Config/ConfigCliCommand.cs new file mode 100644 index 0000000..6de311a --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/ConfigCliCommand.cs @@ -0,0 +1,26 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Config; + +/// +/// Root config command group — wired into +/// TxcCliCommand.Children so txc config … is invocable and +/// MCP discovers config_* tools. Alias c keeps day-to-day +/// typing short (e.g. txc c p select customer-a-dev). +/// +[CliCommand( + Name = "config", + Aliases = new[] { "c" }, + Description = "Manage txc profiles, connections, credentials, and settings.", + Children = new[] + { + typeof(Auth.AuthCliCommand), + typeof(Connection.ConnectionCliCommand), + typeof(Profile.ProfileCliCommand), + typeof(Setting.SettingCliCommand), + } +)] +public class ConfigCliCommand +{ + public void Run(CliContext context) => context.ShowHelp(); +} diff --git a/src/TALXIS.CLI.Features.Config/Connection/ConnectionCliCommand.cs b/src/TALXIS.CLI.Features.Config/Connection/ConnectionCliCommand.cs new file mode 100644 index 0000000..b3b35a4 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Connection/ConnectionCliCommand.cs @@ -0,0 +1,27 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Config.Connection; + +/// +/// txc config connection — Connections are the "where": service +/// endpoint metadata (URLs, tenant ids, org ids) that's identity-neutral +/// and can be safely committed by teams who share environments. +/// V1 ships the Dataverse provider only — --provider accepts +/// dataverse and rejects all other values with an explicit +/// "not implemented in v1" error (see plan §provider-stubs). +/// +[CliCommand( + Name = "connection", + Description = "Manage service endpoint metadata (Dataverse environments, etc.).", + Children = new[] + { + typeof(ConnectionCreateCliCommand), + typeof(ConnectionListCliCommand), + typeof(ConnectionShowCliCommand), + typeof(ConnectionDeleteCliCommand), + } +)] +public class ConnectionCliCommand +{ + public void Run(CliContext context) => context.ShowHelp(); +} diff --git a/src/TALXIS.CLI.Features.Config/Connection/ConnectionCreateCliCommand.cs b/src/TALXIS.CLI.Features.Config/Connection/ConnectionCreateCliCommand.cs new file mode 100644 index 0000000..03590db --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Connection/ConnectionCreateCliCommand.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Bootstrapping; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Connection; + +/// +/// txc config connection create — register a service endpoint. +/// Dataverse-only in v1 (the only provider with a live implementation); +/// other values return exit 1 with a clear +/// "not implemented in v1" message so future provider packages can plug +/// in without surface churn. +/// +[CliCommand( + Name = "create", + Description = "Create a connection (service endpoint). Dataverse-only in v1." +)] +public class ConnectionCreateCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ConnectionCreateCliCommand)); + + [CliArgument(Description = "Connection name.")] + public required string Name { get; set; } + + [CliOption(Name = "--provider", Description = "Connection provider. Only 'dataverse' is supported in v1.", Required = true)] + public ProviderKind Provider { get; set; } + + [CliOption(Name = "--environment", Aliases = new[] { "--url" }, Description = "Dataverse environment URL (required for --provider dataverse).", Required = false)] + public string? EnvironmentUrl { get; set; } + + [CliOption(Name = "--cloud", Description = "Sovereign cloud for Dataverse. Default: public.", Required = false)] + public CloudInstance? Cloud { get; set; } + + [CliOption(Name = "--organization-id", Aliases = new[] { "--org-id" }, Description = "Dataverse organization id (GUID). Optional.", Required = false)] + public string? OrganizationId { get; set; } + + [CliOption(Name = "--tenant", Description = "Entra tenant id or domain. Optional — defaults to the credential's tenant at resolve time.", Required = false)] + public string? TenantId { get; set; } + + [CliOption(Name = "--description", Description = "Free-form label shown in 'config connection list'.", Required = false)] + public string? Description { get; set; } + + public async Task RunAsync() + { + try + { + var svc = TxcServices.Get(); + var upsert = await svc.ValidateAndUpsertAsync( + Name, + Provider, + EnvironmentUrl, + Cloud, + OrganizationId, + TenantId, + Description, + CancellationToken.None).ConfigureAwait(false); + + if (upsert.Error is not null) + { + _logger.LogError("{Message}", upsert.Error); + return 1; + } + + var connection = upsert.Connection!; + _logger.LogInformation("Connection '{Name}' saved ({Provider} -> {Env}).", + connection.Id, connection.Provider, connection.EnvironmentUrl); + + OutputWriter.WriteLine(JsonSerializer.Serialize( + new + { + id = connection.Id, + provider = connection.Provider, + environmentUrl = connection.EnvironmentUrl, + cloud = connection.Cloud, + organizationId = connection.OrganizationId, + tenantId = connection.TenantId, + description = connection.Description, + }, + TxcJsonOptions.Default)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create connection '{Name}'.", Name); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Connection/ConnectionDeleteCliCommand.cs b/src/TALXIS.CLI.Features.Config/Connection/ConnectionDeleteCliCommand.cs new file mode 100644 index 0000000..bc7081a --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Connection/ConnectionDeleteCliCommand.cs @@ -0,0 +1,92 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Connection; + +/// +/// txc config connection delete <name> — removes a +/// connection. By default the command fails with exit 3 when one or +/// more profiles reference it (surfacing the profile ids in the error), +/// so the user can decide to rebind or delete them first. +/// --force-orphan-profiles opts into the parity behaviour of +/// config auth delete: the connection is removed and the +/// referring profiles are left orphaned with a warning each. +/// +[McpIgnore] +[CliCommand( + Name = "delete", + Description = "Delete a connection. Fails if profiles reference it unless --force-orphan-profiles." +)] +public class ConnectionDeleteCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ConnectionDeleteCliCommand)); + + [CliArgument(Description = "Connection name.")] + public required string Name { get; set; } + + [CliOption(Name = "--force-orphan-profiles", Description = "Delete even if profiles reference this connection; leaves them orphaned.", Required = false)] + public bool ForceOrphanProfiles { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Name)) + { + _logger.LogError("Connection name must be provided."); + return 1; + } + + try + { + var connStore = TxcServices.Get(); + var profileStore = TxcServices.Get(); + + var existing = await connStore.GetAsync(Name, CancellationToken.None).ConfigureAwait(false); + if (existing is null) + { + _logger.LogError("Connection '{Name}' not found.", Name); + return 2; + } + + var profiles = await profileStore.ListAsync(CancellationToken.None).ConfigureAwait(false); + var referencing = profiles + .Where(p => string.Equals(p.ConnectionRef, Name, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (referencing.Count > 0 && !ForceOrphanProfiles) + { + _logger.LogError( + "Connection '{Name}' is referenced by profile(s): {Profiles}. " + + "Rebind or delete them first, or pass --force-orphan-profiles.", + Name, string.Join(", ", referencing.Select(p => $"'{p.Id}'"))); + return 3; + } + + foreach (var p in referencing) + { + _logger.LogWarning( + "Profile '{ProfileId}' referenced connection '{Name}' and is now orphaned. " + + "Update or delete the profile explicitly.", + p.Id, Name); + } + + var removed = await connStore.DeleteAsync(Name, CancellationToken.None).ConfigureAwait(false); + if (!removed) + { + _logger.LogError("Connection '{Name}' disappeared during delete.", Name); + return 1; + } + + _logger.LogInformation("Connection '{Name}' deleted.", Name); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete connection '{Name}'.", Name); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Connection/ConnectionListCliCommand.cs b/src/TALXIS.CLI.Features.Config/Connection/ConnectionListCliCommand.cs new file mode 100644 index 0000000..1c05581 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Connection/ConnectionListCliCommand.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Connection; + +/// +/// txc config connection list — JSON dump of all connections. +/// +[CliCommand( + Name = "list", + Description = "List connections as JSON." +)] +public class ConnectionListCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ConnectionListCliCommand)); + + public async Task RunAsync() + { + try + { + var store = TxcServices.Get(); + var connections = await store.ListAsync(CancellationToken.None).ConfigureAwait(false); + OutputWriter.WriteLine(JsonSerializer.Serialize(connections, TxcJsonOptions.Default)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to list connections."); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Connection/ConnectionShowCliCommand.cs b/src/TALXIS.CLI.Features.Config/Connection/ConnectionShowCliCommand.cs new file mode 100644 index 0000000..ae6d96b --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Connection/ConnectionShowCliCommand.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Connection; + +/// +/// txc config connection show <name> — emit a single +/// connection as JSON. Exits with code 2 when the connection is not +/// found (so scripts can distinguish "missing" from "error"). +/// +[CliCommand( + Name = "show", + Description = "Show a single connection as JSON. Exit 2 if not found." +)] +public class ConnectionShowCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ConnectionShowCliCommand)); + + [CliArgument(Description = "Connection name.")] + public required string Name { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Name)) + { + _logger.LogError("Connection name must be provided."); + return 1; + } + + try + { + var store = TxcServices.Get(); + var connection = await store.GetAsync(Name, CancellationToken.None).ConfigureAwait(false); + if (connection is null) + { + _logger.LogError("Connection '{Name}' not found.", Name); + return 2; + } + + OutputWriter.WriteLine(JsonSerializer.Serialize(connection, TxcJsonOptions.Default)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to show connection '{Name}'.", Name); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Profile/ProfileCliCommand.cs b/src/TALXIS.CLI.Features.Config/Profile/ProfileCliCommand.cs new file mode 100644 index 0000000..27aa866 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Profile/ProfileCliCommand.cs @@ -0,0 +1,31 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Config.Profile; + +/// +/// txc config profile — Profiles are the "context": a named +/// binding of one Connection (the "where") to one Credential (the +/// "who"). Profiles are the only primitive that leaf commands consume +/// via --profile. +/// +[CliCommand( + Name = "profile", + Aliases = new[] { "p" }, + Description = "Manage profiles (bind one auth to one connection).", + Children = new[] + { + typeof(ProfileCreateCliCommand), + typeof(ProfileListCliCommand), + typeof(ProfileShowCliCommand), + typeof(ProfileUpdateCliCommand), + typeof(ProfileSelectCliCommand), + typeof(ProfilePinCliCommand), + typeof(ProfileUnpinCliCommand), + typeof(ProfileValidateCliCommand), + typeof(ProfileDeleteCliCommand), + } +)] +public class ProfileCliCommand +{ + public void Run(CliContext context) => context.ShowHelp(); +} diff --git a/src/TALXIS.CLI.Features.Config/Profile/ProfileCreateCliCommand.cs b/src/TALXIS.CLI.Features.Config/Profile/ProfileCreateCliCommand.cs new file mode 100644 index 0000000..ce5a76e --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Profile/ProfileCreateCliCommand.cs @@ -0,0 +1,270 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Bootstrapping; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; +using ConnectionModel = TALXIS.CLI.Core.Model.Connection; +using ProfileModel = TALXIS.CLI.Core.Model.Profile; + +namespace TALXIS.CLI.Features.Config.Profile; + +/// +/// txc config profile create — either a one-liner bootstrap from +/// a service URL (--url) or an explicit binding of an existing +/// credential to an existing connection (--auth + --connection). +/// +/// +/// The two modes are mutually exclusive and validated before any side +/// effect. The one-liner is the recommended onboarding path; the +/// primitive commands (auth login, connection create) +/// remain for advanced and non-interactive flows that the one-liner +/// cannot model (credential-only setup, one credential across many +/// connections, service-principal onboarding). +/// +/// +/// +/// First profile created is auto-promoted to the global active profile +/// — same behaviour as before. +/// +/// +[CliCommand( + Name = "create", + Description = "Create a profile. Quickstart: --url . Advanced: --auth --connection ." +)] +public class ProfileCreateCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ProfileCreateCliCommand)); + + [CliOption(Name = "--name", Aliases = new[] { "-n" }, Description = "Profile name (slug). Optional — derived from --url host or --connection when omitted.", Required = false)] + public string? Name { get; set; } + + [CliOption(Name = "--url", Description = "Service URL to bootstrap from. Triggers interactive sign-in, credential upsert, and connection creation in one step.", Required = false)] + public string? Url { get; set; } + + [CliOption(Name = "--provider", Description = "Connection provider. Optional in --url mode (inferred from the URL host).", Required = false)] + public ProviderKind? Provider { get; set; } + + [CliOption(Name = "--tenant", Description = "Entra tenant id or domain. Forwarded to interactive sign-in when used with --url.", Required = false)] + public string? Tenant { get; set; } + + [CliOption(Name = "--cloud", Description = "Sovereign cloud. Default: public. Used only with --url.", Required = false)] + public CloudInstance? Cloud { get; set; } + + [CliOption(Name = "--auth", Description = "Existing credential alias (see 'config auth list'). Required in explicit mode.", Required = false)] + public string? Auth { get; set; } + + [CliOption(Name = "--connection", Description = "Existing connection name (see 'config connection list'). Required in explicit mode.", Required = false)] + public string? Connection { get; set; } + + [CliOption(Name = "--description", Description = "Free-form label shown in 'config profile list'.", Required = false)] + public string? Description { get; set; } + + public async Task RunAsync() + { + try + { + var mode = ClassifyMode(); + return mode switch + { + Mode.OneLiner => await RunOneLinerAsync().ConfigureAwait(false), + Mode.Explicit => await RunExplicitAsync().ConfigureAwait(false), + _ => LogUsageError(), + }; + } + catch (HeadlessAuthRequiredException ex) + { + _logger.LogError("{Message}", ex.Message); + return 1; + } + catch (OperationCanceledException) + { + _logger.LogWarning("Profile creation was cancelled."); + return 1; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create profile '{Name}'.", Name); + return 1; + } + } + + private enum Mode { Invalid, OneLiner, Explicit } + + private Mode ClassifyMode() + { + var hasUrl = !string.IsNullOrWhiteSpace(Url); + var hasAuth = !string.IsNullOrWhiteSpace(Auth); + var hasConnection = !string.IsNullOrWhiteSpace(Connection); + + // Mixing --url with --auth/--connection is ambiguous on purpose: the + // two modes write different primitives (one-liner *creates* a + // credential; explicit *references* one). + if (hasUrl && (hasAuth || hasConnection)) return Mode.Invalid; + if (hasUrl) return Mode.OneLiner; + if (hasAuth && hasConnection) return Mode.Explicit; + return Mode.Invalid; + } + + private int LogUsageError() + { + _logger.LogError( + "Specify either --url (quickstart) or --auth --connection (advanced). " + + "Example: txc config profile create --url https://contoso.crm4.dynamics.com/"); + return 1; + } + + private async Task RunOneLinerAsync() + { + var inference = ProviderUrlResolver.Infer(Url); + var provider = Provider ?? inference.Provider; + if (provider is null) + { + _logger.LogError("{Message}", inference.Error); + return 1; + } + if (Provider is not null && inference.Provider is not null && Provider != inference.Provider) + { + _logger.LogWarning( + "Explicit --provider '{Explicit}' overrides URL-inferred '{Inferred}'.", + Provider, inference.Provider); + } + + var bootstrappers = TxcServices.GetAll(); + var bootstrapper = bootstrappers.FirstOrDefault(b => b.Provider == provider.Value); + if (bootstrapper is null) + { + _logger.LogError( + "No one-liner bootstrapper is registered for provider '{Provider}'. " + + "Use the explicit --auth/--connection flow for this provider.", + provider.Value); + return 1; + } + + var profileStore = TxcServices.Get(); + var connectionStore = TxcServices.Get(); + + var name = await ResolveProfileNameAsync(profileStore, connectionStore).ConfigureAwait(false); + if (name is null) + { + _logger.LogError( + "Cannot derive a profile name from --url '{Url}'. Pass --name explicitly.", Url); + return 1; + } + + var request = new ProfileBootstrapRequest( + Name: name, + Provider: provider.Value, + EnvironmentUrl: Url!, + Cloud: Cloud ?? CloudInstance.Public, + TenantId: Tenant, + Description: Description); + + var result = await bootstrapper.BootstrapAsync(request, CancellationToken.None).ConfigureAwait(false); + if (result.Error is not null) + { + _logger.LogError("{Message}", result.Error); + return 1; + } + + return await PersistProfileAsync(name, result.Credential!.Id, result.Connection!.Id, result.Upn).ConfigureAwait(false); + } + + private async Task RunExplicitAsync() + { + var profileStore = TxcServices.Get(); + var connectionStore = TxcServices.Get(); + var credentialStore = TxcServices.Get(); + + var credential = await credentialStore.GetAsync(Auth!.Trim(), CancellationToken.None).ConfigureAwait(false); + if (credential is null) + { + _logger.LogError("Credential '{Alias}' not found. Run 'txc config auth list'.", Auth); + return 2; + } + + var connection = await connectionStore.GetAsync(Connection!.Trim(), CancellationToken.None).ConfigureAwait(false); + if (connection is null) + { + _logger.LogError("Connection '{Name}' not found. Run 'txc config connection list'.", Connection); + return 2; + } + + var name = string.IsNullOrWhiteSpace(Name) ? connection.Id : Name!.Trim(); + if (string.IsNullOrEmpty(name)) + { + _logger.LogError("Profile name must not be empty."); + return 1; + } + + return await PersistProfileAsync(name, credential.Id, connection.Id, upn: null).ConfigureAwait(false); + } + + private async Task ResolveProfileNameAsync(IProfileStore profiles, IConnectionStore connections) + { + var explicitName = string.IsNullOrWhiteSpace(Name) ? null : Name!.Trim(); + if (!string.IsNullOrEmpty(explicitName)) return explicitName; + + var derived = ProviderUrlResolver.DeriveDefaultName(Url); + if (string.IsNullOrEmpty(derived)) return null; + + // Keep profile + connection names aligned so the mental model is + // "one name, one profile, one connection" in the quickstart flow. + // Probe both stores when picking a free suffix. + return await CredentialAliasResolver.ResolveFreeNameAsync( + derived!, + async (candidate, ct) => + await profiles.GetAsync(candidate, ct).ConfigureAwait(false) is not null + || await connections.GetAsync(candidate, ct).ConfigureAwait(false) is not null, + CancellationToken.None).ConfigureAwait(false); + } + + private async Task PersistProfileAsync(string name, string credentialId, string connectionId, string? upn) + { + var profileStore = TxcServices.Get(); + var globalConfig = TxcServices.Get(); + + var profile = new ProfileModel + { + Id = name, + ConnectionRef = connectionId, + CredentialRef = credentialId, + Description = Description, + }; + + await profileStore.UpsertAsync(profile, CancellationToken.None).ConfigureAwait(false); + + var global = await globalConfig.LoadAsync(CancellationToken.None).ConfigureAwait(false); + var promoted = false; + if (string.IsNullOrWhiteSpace(global.ActiveProfile)) + { + global.ActiveProfile = profile.Id; + await globalConfig.SaveAsync(global, CancellationToken.None).ConfigureAwait(false); + promoted = true; + _logger.LogInformation("Profile '{Id}' is now the active profile.", profile.Id); + } + + _logger.LogInformation( + "Profile '{Id}' saved (auth='{Auth}', connection='{Connection}'{Upn}).", + profile.Id, credentialId, connectionId, + string.IsNullOrEmpty(upn) ? string.Empty : $", upn='{upn}'"); + + OutputWriter.WriteLine(JsonSerializer.Serialize( + new + { + id = profile.Id, + connectionRef = profile.ConnectionRef, + credentialRef = profile.CredentialRef, + description = profile.Description, + active = promoted, + upn, + }, + TxcJsonOptions.Default)); + return 0; + } +} diff --git a/src/TALXIS.CLI.Features.Config/Profile/ProfileDeleteCliCommand.cs b/src/TALXIS.CLI.Features.Config/Profile/ProfileDeleteCliCommand.cs new file mode 100644 index 0000000..208b597 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Profile/ProfileDeleteCliCommand.cs @@ -0,0 +1,130 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Profile; + +/// +/// txc config profile delete <name> — remove a profile. +/// +/// +/// Default behaviour keeps dependents (the linked credential and +/// connection) so other profiles aren't broken; pass --cascade +/// to also remove them IF no other profile references them. The +/// active-profile pointer is cleared when the deleted profile was +/// active — scripts that rely on an active profile then fail fast +/// instead of silently resolving to a deleted one. +/// +/// +[McpIgnore] +[CliCommand( + Name = "delete", + Description = "Delete a profile. Dependents are kept unless --cascade." +)] +public class ProfileDeleteCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ProfileDeleteCliCommand)); + + [CliArgument(Description = "Profile name.")] + public required string Name { get; set; } + + [CliOption(Name = "--cascade", Description = "Also delete the linked auth + connection (only if no other profile uses them).", Required = false)] + public bool Cascade { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Name)) + { + _logger.LogError("Profile name must be provided."); + return 1; + } + + try + { + var profileStore = TxcServices.Get(); + var connectionStore = TxcServices.Get(); + var credentialStore = TxcServices.Get(); + var globalConfig = TxcServices.Get(); + var vault = TxcServices.Get(); + + var existing = await profileStore.GetAsync(Name, CancellationToken.None).ConfigureAwait(false); + if (existing is null) + { + _logger.LogError("Profile '{Name}' not found.", Name); + return 2; + } + + var removed = await profileStore.DeleteAsync(existing.Id, CancellationToken.None).ConfigureAwait(false); + if (!removed) + { + _logger.LogError("Profile '{Id}' disappeared during delete.", existing.Id); + return 1; + } + + // Clear active pointer if we just removed the active profile — we never + // want leaf commands to resolve to a non-existent profile name. + var global = await globalConfig.LoadAsync(CancellationToken.None).ConfigureAwait(false); + if (string.Equals(global.ActiveProfile, existing.Id, StringComparison.OrdinalIgnoreCase)) + { + global.ActiveProfile = null; + await globalConfig.SaveAsync(global, CancellationToken.None).ConfigureAwait(false); + _logger.LogWarning("Active profile pointer cleared (was '{Id}'). Run 'txc config profile select '.", existing.Id); + } + + if (Cascade) + { + var remaining = await profileStore.ListAsync(CancellationToken.None).ConfigureAwait(false); + + var credStillUsed = remaining.Any(p => + string.Equals(p.CredentialRef, existing.CredentialRef, StringComparison.OrdinalIgnoreCase)); + if (!credStillUsed) + { + var cred = await credentialStore.GetAsync(existing.CredentialRef, CancellationToken.None).ConfigureAwait(false); + if (cred is { SecretRef: { } secretRef }) + { + try { await vault.DeleteSecretAsync(secretRef, CancellationToken.None).ConfigureAwait(false); } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Credential '{Alias}' secret could not be removed from the vault.", + existing.CredentialRef); + } + } + await credentialStore.DeleteAsync(existing.CredentialRef, CancellationToken.None).ConfigureAwait(false); + _logger.LogInformation("Credential '{Alias}' deleted (cascade).", existing.CredentialRef); + } + else + { + _logger.LogInformation( + "Credential '{Alias}' kept (still referenced by another profile).", + existing.CredentialRef); + } + + var connStillUsed = remaining.Any(p => + string.Equals(p.ConnectionRef, existing.ConnectionRef, StringComparison.OrdinalIgnoreCase)); + if (!connStillUsed) + { + await connectionStore.DeleteAsync(existing.ConnectionRef, CancellationToken.None).ConfigureAwait(false); + _logger.LogInformation("Connection '{Name}' deleted (cascade).", existing.ConnectionRef); + } + else + { + _logger.LogInformation( + "Connection '{Name}' kept (still referenced by another profile).", + existing.ConnectionRef); + } + } + + _logger.LogInformation("Profile '{Id}' deleted.", existing.Id); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete profile '{Name}'.", Name); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Profile/ProfileListCliCommand.cs b/src/TALXIS.CLI.Features.Config/Profile/ProfileListCliCommand.cs new file mode 100644 index 0000000..42d21de --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Profile/ProfileListCliCommand.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Profile; + +/// +/// txc config profile list — JSON dump of all profiles. Each +/// entry carries an active flag so scripts can skip running +/// config profile show to figure out which one is current. +/// +[CliCommand( + Name = "list", + Description = "List profiles as JSON." +)] +public class ProfileListCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ProfileListCliCommand)); + + public async Task RunAsync() + { + try + { + var profileStore = TxcServices.Get(); + var globalConfig = TxcServices.Get(); + + var profiles = await profileStore.ListAsync(CancellationToken.None).ConfigureAwait(false); + var global = await globalConfig.LoadAsync(CancellationToken.None).ConfigureAwait(false); + var active = global.ActiveProfile; + + var projected = profiles.Select(p => new + { + id = p.Id, + connectionRef = p.ConnectionRef, + credentialRef = p.CredentialRef, + description = p.Description, + active = string.Equals(p.Id, active, StringComparison.OrdinalIgnoreCase), + }); + + OutputWriter.WriteLine(JsonSerializer.Serialize(projected, TxcJsonOptions.Default)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to list profiles."); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Profile/ProfilePinCliCommand.cs b/src/TALXIS.CLI.Features.Config/Profile/ProfilePinCliCommand.cs new file mode 100644 index 0000000..959fdfa --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Profile/ProfilePinCliCommand.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Profile; + +/// +/// txc config profile pin [<name>] — writes +/// <cwd>/.txc/workspace.json so the active profile is +/// automatically resolved when any txc command runs from this +/// tree (or any child directory). See plan §precedence — a workspace +/// pin beats the global active pointer but is still overridden by +/// --profile and TXC_PROFILE. +/// +/// +/// Without <name> pins the current global active profile +/// (so select then pin is the usual flow); otherwise pins +/// the named profile (must exist). +/// +/// +[McpIgnore] +[CliCommand( + Name = "pin", + Description = "Pin the active profile (or ) to /.txc/workspace.json." +)] +public class ProfilePinCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ProfilePinCliCommand)); + + [CliArgument(Description = "Profile name to pin. Defaults to the global active profile.", Required = false)] + public string? Name { get; set; } + + public async Task RunAsync() + { + try + { + var profileStore = TxcServices.Get(); + var globalConfig = TxcServices.Get(); + var env = TxcServices.Get(); + + string? target = Name; + if (string.IsNullOrWhiteSpace(target)) + { + var global = await globalConfig.LoadAsync(CancellationToken.None).ConfigureAwait(false); + target = global.ActiveProfile; + if (string.IsNullOrWhiteSpace(target)) + { + _logger.LogError("No active profile is set. Pass or run 'txc config profile select ' first."); + return 2; + } + } + + var profile = await profileStore.GetAsync(target!, CancellationToken.None).ConfigureAwait(false); + if (profile is null) + { + _logger.LogError("Profile '{Name}' not found.", target); + return 2; + } + + var cwd = env.GetCurrentDirectory(); + var workspaceDir = Path.Combine(cwd, WorkspaceDiscovery.DirectoryName); + var workspaceFile = Path.Combine(workspaceDir, WorkspaceDiscovery.FileName); + + var config = new WorkspaceConfig { DefaultProfile = profile.Id }; + await JsonFile.WriteAtomicAsync(workspaceFile, config, CancellationToken.None).ConfigureAwait(false); + + _logger.LogInformation("Pinned profile '{Id}' to '{Path}'.", profile.Id, workspaceFile); + + OutputWriter.WriteLine(JsonSerializer.Serialize( + new { profile = profile.Id, path = workspaceFile }, + TxcJsonOptions.Default)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to pin profile."); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Profile/ProfileSelectCliCommand.cs b/src/TALXIS.CLI.Features.Config/Profile/ProfileSelectCliCommand.cs new file mode 100644 index 0000000..e5e4751 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Profile/ProfileSelectCliCommand.cs @@ -0,0 +1,60 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Config.Profile; + +/// +/// txc config profile select <name> — set the global +/// active-profile pointer in ${TXC_CONFIG_DIR}/config.json. +/// Layered precedence in +/// means TXC_PROFILE and .txc/workspace.json still win +/// per invocation; select only changes the fallback. +/// +[CliCommand( + Name = "select", + Description = "Set the global active profile (fallback when no --profile / env / workspace override)." +)] +public class ProfileSelectCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ProfileSelectCliCommand)); + + [CliArgument(Description = "Profile name to set as active.")] + public required string Name { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Name)) + { + _logger.LogError("Profile name must be provided."); + return 1; + } + + try + { + var profileStore = TxcServices.Get(); + var globalConfig = TxcServices.Get(); + + var profile = await profileStore.GetAsync(Name, CancellationToken.None).ConfigureAwait(false); + if (profile is null) + { + _logger.LogError("Profile '{Name}' not found.", Name); + return 2; + } + + var global = await globalConfig.LoadAsync(CancellationToken.None).ConfigureAwait(false); + global.ActiveProfile = profile.Id; + await globalConfig.SaveAsync(global, CancellationToken.None).ConfigureAwait(false); + + _logger.LogInformation("Active profile set to '{Id}'.", profile.Id); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to select profile '{Name}'.", Name); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Profile/ProfileShowCliCommand.cs b/src/TALXIS.CLI.Features.Config/Profile/ProfileShowCliCommand.cs new file mode 100644 index 0000000..80e5d79 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Profile/ProfileShowCliCommand.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Profile; + +/// +/// txc config profile show [<name>] — "whoami"-style detail +/// for a single profile. Without <name> shows the active +/// profile (as pointed to by config.json). Expands the linked +/// connection + credential inline so users can see everything in one +/// blob without three round-trips. +/// +[CliCommand( + Name = "show", + Description = "Show a profile with its expanded connection + credential. Defaults to the active profile." +)] +public class ProfileShowCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ProfileShowCliCommand)); + + [CliArgument(Description = "Profile name. If omitted, shows the active profile.", Required = false)] + public string? Name { get; set; } + + public async Task RunAsync() + { + try + { + var profileStore = TxcServices.Get(); + var connectionStore = TxcServices.Get(); + var credentialStore = TxcServices.Get(); + var globalConfig = TxcServices.Get(); + + string? target = Name; + var global = await globalConfig.LoadAsync(CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(target)) + { + target = global.ActiveProfile; + if (string.IsNullOrWhiteSpace(target)) + { + _logger.LogError("No active profile is set. Pass or run 'txc config profile select '."); + return 2; + } + } + + var profile = await profileStore.GetAsync(target!, CancellationToken.None).ConfigureAwait(false); + if (profile is null) + { + _logger.LogError("Profile '{Name}' not found.", target); + return 2; + } + + // Expand refs so a single `show` gives callers the full picture. + // Missing refs are surfaced as null rather than erroring — `validate` is the command for integrity checks. + var connection = await connectionStore.GetAsync(profile.ConnectionRef, CancellationToken.None).ConfigureAwait(false); + var credential = await credentialStore.GetAsync(profile.CredentialRef, CancellationToken.None).ConfigureAwait(false); + + OutputWriter.WriteLine(JsonSerializer.Serialize( + new + { + id = profile.Id, + active = string.Equals(profile.Id, global.ActiveProfile, StringComparison.OrdinalIgnoreCase), + description = profile.Description, + connection, + credential, + }, + TxcJsonOptions.Default)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to show profile."); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Profile/ProfileUnpinCliCommand.cs b/src/TALXIS.CLI.Features.Config/Profile/ProfileUnpinCliCommand.cs new file mode 100644 index 0000000..467aada --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Profile/ProfileUnpinCliCommand.cs @@ -0,0 +1,60 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Profile; + +/// +/// txc config profile unpin — removes +/// <cwd>/.txc/workspace.json. Idempotent: missing file is +/// not an error (exit 0 with an informational log) so repeated unpin +/// calls don't break scripts. Also removes the empty .txc +/// directory when the workspace file was the only thing inside it. +/// +[McpIgnore] +[CliCommand( + Name = "unpin", + Description = "Remove /.txc/workspace.json (no-op if absent)." +)] +public class ProfileUnpinCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ProfileUnpinCliCommand)); + + public Task RunAsync() + { + try + { + var env = TxcServices.Get(); + var cwd = env.GetCurrentDirectory(); + var workspaceDir = Path.Combine(cwd, WorkspaceDiscovery.DirectoryName); + var workspaceFile = Path.Combine(workspaceDir, WorkspaceDiscovery.FileName); + + if (!File.Exists(workspaceFile)) + { + _logger.LogInformation("No workspace pin found at '{Path}'. Nothing to do.", workspaceFile); + return Task.FromResult(0); + } + + File.Delete(workspaceFile); + _logger.LogInformation("Removed workspace pin at '{Path}'.", workspaceFile); + + // Clean up the `.txc` directory when it's empty so `ls` stays tidy. + if (Directory.Exists(workspaceDir) && + !Directory.EnumerateFileSystemEntries(workspaceDir).Any()) + { + Directory.Delete(workspaceDir); + _logger.LogDebug("Removed empty '{Dir}'.", workspaceDir); + } + + return Task.FromResult(0); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to unpin workspace profile."); + return Task.FromResult(1); + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Profile/ProfileUpdateCliCommand.cs b/src/TALXIS.CLI.Features.Config/Profile/ProfileUpdateCliCommand.cs new file mode 100644 index 0000000..247ae16 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Profile/ProfileUpdateCliCommand.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Profile; + +/// +/// txc config profile update <name> — rebind an existing +/// profile to a different credential (--auth), connection +/// (--connection) or tweak its description. At least one option +/// must be supplied; no-ops are refused with exit 1 so scripts fail +/// loudly instead of silently doing nothing. +/// +[McpIgnore] +[CliCommand( + Name = "update", + Description = "Rebind a profile to a different auth/connection or update its description." +)] +public class ProfileUpdateCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ProfileUpdateCliCommand)); + + [CliArgument(Description = "Profile name.")] + public required string Name { get; set; } + + [CliOption(Name = "--auth", Description = "New credential alias.", Required = false)] + public string? Auth { get; set; } + + [CliOption(Name = "--connection", Description = "New connection name.", Required = false)] + public string? Connection { get; set; } + + [CliOption(Name = "--description", Description = "New description. Pass an empty string to clear.", Required = false)] + public string? Description { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Name)) + { + _logger.LogError("Profile name must be provided."); + return 1; + } + + if (Auth is null && Connection is null && Description is null) + { + _logger.LogError("Nothing to update. Pass --auth, --connection, or --description."); + return 1; + } + + try + { + var profileStore = TxcServices.Get(); + var connectionStore = TxcServices.Get(); + var credentialStore = TxcServices.Get(); + + var existing = await profileStore.GetAsync(Name, CancellationToken.None).ConfigureAwait(false); + if (existing is null) + { + _logger.LogError("Profile '{Name}' not found.", Name); + return 2; + } + + if (Auth is not null) + { + var cred = await credentialStore.GetAsync(Auth, CancellationToken.None).ConfigureAwait(false); + if (cred is null) + { + _logger.LogError("Credential '{Alias}' not found.", Auth); + return 2; + } + existing.CredentialRef = cred.Id; + } + + if (Connection is not null) + { + var conn = await connectionStore.GetAsync(Connection, CancellationToken.None).ConfigureAwait(false); + if (conn is null) + { + _logger.LogError("Connection '{Name}' not found.", Connection); + return 2; + } + existing.ConnectionRef = conn.Id; + } + + if (Description is not null) + { + existing.Description = string.IsNullOrEmpty(Description) ? null : Description; + } + + await profileStore.UpsertAsync(existing, CancellationToken.None).ConfigureAwait(false); + _logger.LogInformation("Profile '{Id}' updated.", existing.Id); + + OutputWriter.WriteLine(JsonSerializer.Serialize(existing, TxcJsonOptions.Default)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update profile '{Name}'.", Name); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Profile/ProfileValidateCliCommand.cs b/src/TALXIS.CLI.Features.Config/Profile/ProfileValidateCliCommand.cs new file mode 100644 index 0000000..aa078c3 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Profile/ProfileValidateCliCommand.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Profile; + +/// +/// txc config profile validate [<name>] — preflights a +/// profile so "will my next command work?" has an explicit answer +/// before long-running operations start. Without <name> +/// validates the global active profile. +/// +/// +/// Runs the provider's structural check (URLs, credential-kind +/// compatibility, authority wiring), then — unless --skip-live +/// is passed — issues a live authenticated round-trip (Dataverse +/// WhoAmI). Exit 0 = success; exit 2 = missing/unreferenced/unsupported +/// provider; exit 1 = validation failure (structural or live). +/// +/// +[McpIgnore] +[CliCommand( + Name = "validate", + Description = "Preflight a profile with structural and live checks." +)] +public class ProfileValidateCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(ProfileValidateCliCommand)); + + [CliArgument(Description = "Profile name to validate. Defaults to the global active profile.", Required = false)] + public string? Name { get; set; } + + [CliOption(Description = "Skip the live authenticated round-trip (WhoAmI); run structural checks only.")] + public bool SkipLive { get; set; } + + public async Task RunAsync() + { + try + { + var profileStore = TxcServices.Get(); + var connectionStore = TxcServices.Get(); + var credentialStore = TxcServices.Get(); + var globalConfig = TxcServices.Get(); + var providers = TxcServices.GetAll(); + + var target = Name; + if (string.IsNullOrWhiteSpace(target)) + { + var gc = await globalConfig.LoadAsync(CancellationToken.None).ConfigureAwait(false); + target = gc.ActiveProfile; + if (string.IsNullOrWhiteSpace(target)) + { + _logger.LogError("No active profile is set. Pass or run 'txc config profile select '."); + return 2; + } + } + + var profile = await profileStore.GetAsync(target!, CancellationToken.None).ConfigureAwait(false); + if (profile is null) + { + _logger.LogError("Profile '{Name}' not found.", target); + return 2; + } + + var connection = await connectionStore.GetAsync(profile.ConnectionRef, CancellationToken.None).ConfigureAwait(false); + if (connection is null) + { + _logger.LogError("Profile '{Profile}' references missing connection '{Connection}'.", profile.Id, profile.ConnectionRef); + return 2; + } + + var credential = await credentialStore.GetAsync(profile.CredentialRef, CancellationToken.None).ConfigureAwait(false); + if (credential is null) + { + _logger.LogError("Profile '{Profile}' references missing credential '{Credential}'.", profile.Id, profile.CredentialRef); + return 2; + } + + var provider = providers.FirstOrDefault(p => p.ProviderKind == connection.Provider); + if (provider is null) + { + _logger.LogError("Provider '{Provider}' is not registered in this build. Dataverse is the only provider shipped in v1.", connection.Provider); + return 2; + } + + var mode = SkipLive ? ValidationMode.Structural : ValidationMode.Live; + try + { + await provider.ValidateAsync(connection, credential, mode, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Validation failed for profile '{Profile}' ({Mode}).", profile.Id, mode); + return 1; + } + + OutputWriter.WriteLine(JsonSerializer.Serialize( + new + { + profile = profile.Id, + connection = connection.Id, + credential = credential.Id, + provider = connection.Provider.ToString().ToLowerInvariant(), + mode = mode.ToString().ToLowerInvariant(), + status = "ok", + }, + TxcJsonOptions.Default)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to validate profile."); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Setting/SettingCliCommand.cs b/src/TALXIS.CLI.Features.Config/Setting/SettingCliCommand.cs new file mode 100644 index 0000000..1fa0d54 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Setting/SettingCliCommand.cs @@ -0,0 +1,24 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Config.Setting; + +/// +/// txc config setting — tool-wide preference verbs. Backed by +/// ${TXC_CONFIG_DIR:-~/.txc}/config.json (same file that carries +/// the active-profile pointer). The key surface is intentionally narrow +/// in v1 — see . +/// +[CliCommand( + Name = "setting", + Description = "Manage tool-wide preferences (log, telemetry) in config.json.", + Children = new[] + { + typeof(SettingSetCliCommand), + typeof(SettingGetCliCommand), + typeof(SettingListCliCommand), + } +)] +public class SettingCliCommand +{ + public void Run(CliContext context) => context.ShowHelp(); +} diff --git a/src/TALXIS.CLI.Features.Config/Setting/SettingGetCliCommand.cs b/src/TALXIS.CLI.Features.Config/Setting/SettingGetCliCommand.cs new file mode 100644 index 0000000..ec82bfa --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Setting/SettingGetCliCommand.cs @@ -0,0 +1,58 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Setting; + +/// +/// txc config setting get <key> — print the current value of +/// one whitelisted setting to stdout. Unknown keys exit 2 with a hint +/// listing the known keys so shell scripts can distinguish "empty" +/// (default) from "typo". +/// +[CliCommand( + Name = "get", + Description = "Get a tool-wide preference value by key." +)] +public class SettingGetCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(SettingGetCliCommand)); + + [CliArgument(Description = "Setting key (e.g. log.level).")] + public required string Key { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Key)) + { + _logger.LogError("Setting key must be provided."); + return 1; + } + + var descriptor = SettingRegistry.Find(Key); + if (descriptor is null) + { + _logger.LogError( + "Unknown setting key '{Key}'. Known keys: {Keys}.", + Key, + string.Join(", ", SettingRegistry.All.Select(d => d.Key))); + return 2; + } + + try + { + var store = TxcServices.Get(); + var config = await store.LoadAsync(CancellationToken.None).ConfigureAwait(false); + OutputWriter.WriteLine(descriptor.Read(config)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to read setting '{Key}'.", descriptor.Key); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Setting/SettingListCliCommand.cs b/src/TALXIS.CLI.Features.Config/Setting/SettingListCliCommand.cs new file mode 100644 index 0000000..348b8f7 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Setting/SettingListCliCommand.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Config.Setting; + +/// +/// txc config setting list — JSON dump of every whitelisted key, +/// its current value, and its allowed values. Intended as the one-stop +/// discovery surface so users don't have to hunt through docs to find +/// the supported keys. +/// +[CliCommand( + Name = "list", + Description = "List all known setting keys with current values as JSON." +)] +public class SettingListCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(SettingListCliCommand)); + + public async Task RunAsync() + { + try + { + var store = TxcServices.Get(); + var config = await store.LoadAsync(CancellationToken.None).ConfigureAwait(false); + + var projected = SettingRegistry.All.Select(d => new + { + key = d.Key, + value = d.Read(config), + description = d.Description, + allowedValues = d.AllowedValues, + }); + + OutputWriter.WriteLine(JsonSerializer.Serialize(projected, TxcJsonOptions.Default)); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to list settings."); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Features.Config/Setting/SettingRegistry.cs b/src/TALXIS.CLI.Features.Config/Setting/SettingRegistry.cs new file mode 100644 index 0000000..3b4e1d2 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Setting/SettingRegistry.cs @@ -0,0 +1,75 @@ +using System.Globalization; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Features.Config.Setting; + +/// +/// Whitelist of tool-wide setting keys exposed via txc config setting. +/// Kept intentionally narrow in v1 — no free-form key-value soup. Each +/// descriptor knows how to read, write, and validate its slot on +/// so the CLI verbs stay small and uniform. +/// +internal static class SettingRegistry +{ + public static readonly IReadOnlyList All = new SettingDescriptor[] + { + new( + "log.level", + "Minimum log level for diagnostic output (stderr).", + new[] { "trace", "debug", "information", "warning", "error", "critical", "none" }, + g => g.Log.Level, + (g, v) => g.Log.Level = v), + new( + "log.format", + "Diagnostic log rendering format.", + new[] { "plain", "json" }, + g => g.Log.Format, + (g, v) => g.Log.Format = v), + new( + "telemetry.enabled", + "Whether anonymous usage telemetry is enabled.", + null, + g => g.Telemetry.Enabled ? "true" : "false", + (g, v) => g.Telemetry.Enabled = ParseBool(v)), + }; + + public static SettingDescriptor? Find(string key) + => All.FirstOrDefault(d => string.Equals(d.Key, key, StringComparison.OrdinalIgnoreCase)); + + public static string NormalizeValue(SettingDescriptor descriptor, string raw) + { + if (descriptor.Key == "telemetry.enabled") + return ParseBool(raw) ? "true" : "false"; + + var lowered = raw.Trim().ToLowerInvariant(); + if (descriptor.AllowedValues is { } allowed) + { + var match = allowed.FirstOrDefault(a => + string.Equals(a, lowered, StringComparison.OrdinalIgnoreCase)); + if (match is null) + throw new ArgumentException( + $"Invalid value '{raw}' for '{descriptor.Key}'. Allowed: {string.Join(", ", allowed)}."); + return match; + } + return lowered; + } + + private static bool ParseBool(string raw) + { + var v = raw.Trim().ToLowerInvariant(); + return v switch + { + "true" or "1" or "yes" or "on" => true, + "false" or "0" or "no" or "off" => false, + _ => throw new ArgumentException( + $"Invalid boolean value '{raw}'. Allowed: true, false."), + }; + } +} + +internal sealed record SettingDescriptor( + string Key, + string Description, + IReadOnlyList? AllowedValues, + Func Read, + Action Write); diff --git a/src/TALXIS.CLI.Features.Config/Setting/SettingSetCliCommand.cs b/src/TALXIS.CLI.Features.Config/Setting/SettingSetCliCommand.cs new file mode 100644 index 0000000..6fc7121 --- /dev/null +++ b/src/TALXIS.CLI.Features.Config/Setting/SettingSetCliCommand.cs @@ -0,0 +1,74 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Config.Setting; + +/// +/// txc config setting set <key> <value> — update one +/// tool-wide preference. Unknown keys are rejected (exit 2) so typos +/// never silently write garbage into config.json; values are +/// validated against the per-key whitelist in . +/// +[CliCommand( + Name = "set", + Description = "Set a tool-wide preference key (e.g. log.level, log.format, telemetry.enabled)." +)] +public class SettingSetCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(SettingSetCliCommand)); + + [CliArgument(Description = "Setting key (e.g. log.level).")] + public required string Key { get; set; } + + [CliArgument(Description = "New value. Run 'txc config setting list' to see allowed values per key.")] + public required string Value { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Key)) + { + _logger.LogError("Setting key must be provided."); + return 1; + } + + var descriptor = SettingRegistry.Find(Key); + if (descriptor is null) + { + _logger.LogError( + "Unknown setting key '{Key}'. Known keys: {Keys}.", + Key, + string.Join(", ", SettingRegistry.All.Select(d => d.Key))); + return 2; + } + + string normalized; + try + { + normalized = SettingRegistry.NormalizeValue(descriptor, Value); + } + catch (ArgumentException ex) + { + _logger.LogError("{Message}", ex.Message); + return 2; + } + + try + { + var store = TxcServices.Get(); + var config = await store.LoadAsync(CancellationToken.None).ConfigureAwait(false); + descriptor.Write(config, normalized); + await store.SaveAsync(config, CancellationToken.None).ConfigureAwait(false); + + _logger.LogInformation("Setting '{Key}' updated to '{Value}'.", descriptor.Key, normalized); + return 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update setting '{Key}'.", descriptor.Key); + return 1; + } + } +} diff --git a/src/TALXIS.CLI.Environment/TALXIS.CLI.Environment.csproj b/src/TALXIS.CLI.Features.Config/TALXIS.CLI.Features.Config.csproj similarity index 62% rename from src/TALXIS.CLI.Environment/TALXIS.CLI.Environment.csproj rename to src/TALXIS.CLI.Features.Config/TALXIS.CLI.Features.Config.csproj index 4ab263d..e16fcc8 100644 --- a/src/TALXIS.CLI.Environment/TALXIS.CLI.Environment.csproj +++ b/src/TALXIS.CLI.Features.Config/TALXIS.CLI.Features.Config.csproj @@ -6,15 +6,17 @@ enable + + + + - + - - diff --git a/src/TALXIS.CLI.Data/DataCliCommand.cs b/src/TALXIS.CLI.Features.Data/DataCliCommand.cs similarity index 90% rename from src/TALXIS.CLI.Data/DataCliCommand.cs rename to src/TALXIS.CLI.Features.Data/DataCliCommand.cs index 2feb440..0103bdb 100644 --- a/src/TALXIS.CLI.Data/DataCliCommand.cs +++ b/src/TALXIS.CLI.Features.Data/DataCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Data; +namespace TALXIS.CLI.Features.Data; [CliCommand( Description = "Data utilities for modeling, demostration, migration and integration", diff --git a/src/TALXIS.CLI.Data/DataModelCliCommand.cs b/src/TALXIS.CLI.Features.Data/DataModelCliCommand.cs similarity index 88% rename from src/TALXIS.CLI.Data/DataModelCliCommand.cs rename to src/TALXIS.CLI.Features.Data/DataModelCliCommand.cs index 764a9c3..c80ea7b 100644 --- a/src/TALXIS.CLI.Data/DataModelCliCommand.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Data; +namespace TALXIS.CLI.Features.Data; [CliCommand( Name = "model", diff --git a/src/TALXIS.CLI.Data/DataModelConvertCliCommand.cs b/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs similarity index 92% rename from src/TALXIS.CLI.Data/DataModelConvertCliCommand.cs rename to src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs index 438af22..edc28b6 100644 --- a/src/TALXIS.CLI.Data/DataModelConvertCliCommand.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConvertCliCommand.cs @@ -1,8 +1,8 @@ using DotMake.CommandLine; -using TALXIS.CLI.Data.DataModelConverter; -using TALXIS.CLI.Shared; +using TALXIS.CLI.Features.Data.DataModelConverter; +using TALXIS.CLI.Core; -namespace TALXIS.CLI.Data; +namespace TALXIS.CLI.Features.Data; [CliCommand( Name = "convert", @@ -66,7 +66,7 @@ private static void EnsureGitIgnored(string exportsDirPath) if (lines.Any(l => l.Trim() == entry)) return; - File.AppendAllText(gitIgnorePath, $"{Environment.NewLine}{entry}{Environment.NewLine}"); + File.AppendAllText(gitIgnorePath, $"{System.Environment.NewLine}{entry}{System.Environment.NewLine}"); } private static string? FindGitIgnore(string startPath) diff --git a/src/TALXIS.CLI.Data/DataModelConverter/DataModelConverterService.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs similarity index 99% rename from src/TALXIS.CLI.Data/DataModelConverter/DataModelConverterService.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs index a7f8e84..39f4945 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/DataModelConverterService.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/DataModelConverterService.cs @@ -8,12 +8,12 @@ using System.Xml.Linq; using System.Xml.Serialization; using Microsoft.Extensions.Logging; -using TALXIS.CLI.Data.DataModelConverter.Extensions; -using TALXIS.CLI.Data.DataModelConverter.Model; -using TALXIS.CLI.Data.DataModelConverter.Translators; +using TALXIS.CLI.Features.Data.DataModelConverter.Extensions; +using TALXIS.CLI.Features.Data.DataModelConverter.Model; +using TALXIS.CLI.Features.Data.DataModelConverter.Translators; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Data.DataModelConverter; +namespace TALXIS.CLI.Features.Data.DataModelConverter; public class DataModelConverterService { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Extensions/StringExtension.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Extensions/StringExtension.cs similarity index 95% rename from src/TALXIS.CLI.Data/DataModelConverter/Extensions/StringExtension.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Extensions/StringExtension.cs index 2f3e597..5320bb5 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Extensions/StringExtension.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Extensions/StringExtension.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Text; -namespace TALXIS.CLI.Data.DataModelConverter.Extensions; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Extensions; public static class StringExtension { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Extensions/TableExtension.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Extensions/TableExtension.cs similarity index 84% rename from src/TALXIS.CLI.Data/DataModelConverter/Extensions/TableExtension.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Extensions/TableExtension.cs index 9a9205d..2addd7a 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Extensions/TableExtension.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Extensions/TableExtension.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; -using TALXIS.CLI.Data.DataModelConverter.Model; +using TALXIS.CLI.Features.Data.DataModelConverter.Model; -namespace TALXIS.CLI.Data.DataModelConverter.Extensions; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Extensions; public static class TableExtension { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Model/DataverseToSqlTypeMapper.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/DataverseToSqlTypeMapper.cs similarity index 91% rename from src/TALXIS.CLI.Data/DataModelConverter/Model/DataverseToSqlTypeMapper.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Model/DataverseToSqlTypeMapper.cs index 7c7a308..b885d96 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Model/DataverseToSqlTypeMapper.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/DataverseToSqlTypeMapper.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace TALXIS.CLI.Data.DataModelConverter.Model; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; public class DataverseToSqlTypeMapper { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Model/Module.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Module.cs similarity index 94% rename from src/TALXIS.CLI.Data/DataModelConverter/Model/Module.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Module.cs index edd62aa..4a8ea5d 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Model/Module.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Module.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using System.Xml.Linq; -namespace TALXIS.CLI.Data.DataModelConverter.Model; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; public class Module { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Model/OptionsetEnum.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/OptionsetEnum.cs similarity index 94% rename from src/TALXIS.CLI.Data/DataModelConverter/Model/OptionsetEnum.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Model/OptionsetEnum.cs index 0ae22d9..9a4e4fe 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Model/OptionsetEnum.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/OptionsetEnum.cs @@ -4,7 +4,7 @@ using System.Text; using System.Threading.Tasks; -namespace TALXIS.CLI.Data.DataModelConverter.Model; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; public class OptionsetEnum { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Model/OptionsetRow.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/OptionsetRow.cs similarity index 82% rename from src/TALXIS.CLI.Data/DataModelConverter/Model/OptionsetRow.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Model/OptionsetRow.cs index fefbbf4..45b38e7 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Model/OptionsetRow.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/OptionsetRow.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Text; -namespace TALXIS.CLI.Data.DataModelConverter.Model; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; public class OptionsetRow { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Model/ParsedModel.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/ParsedModel.cs similarity index 79% rename from src/TALXIS.CLI.Data/DataModelConverter/Model/ParsedModel.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Model/ParsedModel.cs index 23cacb9..24ed539 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Model/ParsedModel.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/ParsedModel.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Text; -namespace TALXIS.CLI.Data.DataModelConverter.Model; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; public class ParsedModel { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Model/Relationship.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Relationship.cs similarity index 94% rename from src/TALXIS.CLI.Data/DataModelConverter/Model/Relationship.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Relationship.cs index b0425ad..06b056d 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Model/Relationship.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Relationship.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Text; -namespace TALXIS.CLI.Data.DataModelConverter.Model; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; public class Relationship { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Model/RibbonDiff.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/RibbonDiff.cs similarity index 99% rename from src/TALXIS.CLI.Data/DataModelConverter/Model/RibbonDiff.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Model/RibbonDiff.cs index 9ecec07..d4cae84 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Model/RibbonDiff.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/RibbonDiff.cs @@ -1,6 +1,6 @@ using System.Xml.Serialization; using System.Collections.Generic; -namespace TALXIS.CLI.Data.DataModelConverter.Model; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; [XmlRoot(ElementName = "Button")] public class Button diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Model/RowType.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/RowType.cs similarity index 86% rename from src/TALXIS.CLI.Data/DataModelConverter/Model/RowType.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Model/RowType.cs index 986c5a1..bf6afc6 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Model/RowType.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/RowType.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Data.DataModelConverter.Model; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; public enum RowType { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Model/Table.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Table.cs similarity index 95% rename from src/TALXIS.CLI.Data/DataModelConverter/Model/Table.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Table.cs index 5547b57..655676c 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Model/Table.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/Table.cs @@ -6,9 +6,9 @@ using System.Text; using System.Xml.Linq; using System.Xml.Serialization; -using TALXIS.CLI.Data.DataModelConverter.Extensions; +using TALXIS.CLI.Features.Data.DataModelConverter.Extensions; -namespace TALXIS.CLI.Data.DataModelConverter.Model; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; public enum TableType diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Model/TableRow.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/TableRow.cs similarity index 97% rename from src/TALXIS.CLI.Data/DataModelConverter/Model/TableRow.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Model/TableRow.cs index fd0a1ac..70aa4f3 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Model/TableRow.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Model/TableRow.cs @@ -4,10 +4,10 @@ using System.Text; using System.Xml.Linq; using Microsoft.Extensions.Logging; -using TALXIS.CLI.Data.DataModelConverter.Extensions; +using TALXIS.CLI.Features.Data.DataModelConverter.Extensions; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Data.DataModelConverter.Model; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Model; public class TableRow diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Translators/DBDiagramTranslator.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Translators/DBDiagramTranslator.cs similarity index 94% rename from src/TALXIS.CLI.Data/DataModelConverter/Translators/DBDiagramTranslator.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Translators/DBDiagramTranslator.cs index a18be72..8b7f438 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Translators/DBDiagramTranslator.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Translators/DBDiagramTranslator.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Text; -using TALXIS.CLI.Data.DataModelConverter.Model; +using TALXIS.CLI.Features.Data.DataModelConverter.Model; -namespace TALXIS.CLI.Data.DataModelConverter.Translators; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Translators; public static class DBDiagramTranslator { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Translators/EDMXTranslator.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Translators/EDMXTranslator.cs similarity index 97% rename from src/TALXIS.CLI.Data/DataModelConverter/Translators/EDMXTranslator.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Translators/EDMXTranslator.cs index 7c004ec..6e0959f 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Translators/EDMXTranslator.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Translators/EDMXTranslator.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Linq; using System.Text; -using TALXIS.CLI.Data.DataModelConverter.Model; +using TALXIS.CLI.Features.Data.DataModelConverter.Model; -namespace TALXIS.CLI.Data.DataModelConverter.Translators; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Translators; public static class EDMXTranslator { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/Translators/SQLTranslator.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/Translators/SQLTranslator.cs similarity index 98% rename from src/TALXIS.CLI.Data/DataModelConverter/Translators/SQLTranslator.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/Translators/SQLTranslator.cs index 8d0e0f9..277719b 100644 --- a/src/TALXIS.CLI.Data/DataModelConverter/Translators/SQLTranslator.cs +++ b/src/TALXIS.CLI.Features.Data/DataModelConverter/Translators/SQLTranslator.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Linq; using System.Text; -using TALXIS.CLI.Data.DataModelConverter.Model; +using TALXIS.CLI.Features.Data.DataModelConverter.Model; -namespace TALXIS.CLI.Data.DataModelConverter.Translators; +namespace TALXIS.CLI.Features.Data.DataModelConverter.Translators; public static class SQLTranslator { diff --git a/src/TALXIS.CLI.Data/DataModelConverter/XMLSchemas/OptionSetXmlSchema.cs b/src/TALXIS.CLI.Features.Data/DataModelConverter/XMLSchemas/OptionSetXmlSchema.cs similarity index 100% rename from src/TALXIS.CLI.Data/DataModelConverter/XMLSchemas/OptionSetXmlSchema.cs rename to src/TALXIS.CLI.Features.Data/DataModelConverter/XMLSchemas/OptionSetXmlSchema.cs diff --git a/src/TALXIS.CLI.Data/DataPackageCliCommand.cs b/src/TALXIS.CLI.Features.Data/DataPackageCliCommand.cs similarity index 91% rename from src/TALXIS.CLI.Data/DataPackageCliCommand.cs rename to src/TALXIS.CLI.Features.Data/DataPackageCliCommand.cs index e77f021..a060cd7 100644 --- a/src/TALXIS.CLI.Data/DataPackageCliCommand.cs +++ b/src/TALXIS.CLI.Features.Data/DataPackageCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Data; +namespace TALXIS.CLI.Features.Data; [CliCommand( Name = "package", diff --git a/src/TALXIS.CLI.Data/DataPackageConvertCliCommand.cs b/src/TALXIS.CLI.Features.Data/DataPackageConvertCliCommand.cs similarity index 99% rename from src/TALXIS.CLI.Data/DataPackageConvertCliCommand.cs rename to src/TALXIS.CLI.Features.Data/DataPackageConvertCliCommand.cs index 7b86fdf..07bff97 100644 --- a/src/TALXIS.CLI.Data/DataPackageConvertCliCommand.cs +++ b/src/TALXIS.CLI.Features.Data/DataPackageConvertCliCommand.cs @@ -1,7 +1,7 @@ using DotMake.CommandLine; -using TALXIS.CLI.Shared; +using TALXIS.CLI.Core; -namespace TALXIS.CLI.Data; +namespace TALXIS.CLI.Features.Data; [CliCommand( Name = "convert", diff --git a/src/TALXIS.CLI.Features.Data/DataPackageImportCliCommand.cs b/src/TALXIS.CLI.Features.Data/DataPackageImportCliCommand.cs new file mode 100644 index 0000000..449c22f --- /dev/null +++ b/src/TALXIS.CLI.Features.Data/DataPackageImportCliCommand.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Features.Config.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Data; + +[CliCommand( + Name = "import", + Description = "Import a CMT data package into a Dataverse environment" +)] +public class DataPackageImportCliCommand : ProfiledCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(DataPackageImportCliCommand)); + + [CliArgument(Description = "Path to the CMT data package (.zip file or folder containing data.xml and data_schema.xml)")] + public required string Data { get; set; } + + [CliOption(Name = "--connection-count", Description = "Number of parallel connections for data import.", Required = false)] + [DefaultValue(1)] + public int ConnectionCount { get; set; } = 1; + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Data)) + { + _logger.LogError("A path to a CMT data package (.zip or folder) must be provided."); + return 1; + } + + if (!File.Exists(Data) && !Directory.Exists(Data)) + { + _logger.LogError("Data package not found: {DataPath}", Data); + return 1; + } + + var service = TxcServices.Get(); + DataPackageImportResult result; + try + { + result = await service.ImportAsync(Profile, Data, ConnectionCount, Verbose, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Data import failed"); + _logger.LogError("Data package: {DataPath}", Path.GetFullPath(Data)); + return 1; + } + + if (result.InteractiveAuthRequired) + { + _logger.LogError("Interactive authentication is required. Run 'txc config auth login' for profile '{Profile}' and retry.", Profile ?? "(default)"); + return 1; + } + + if (!result.Succeeded) + { + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + { + _logger.LogError("{ErrorMessage}", result.ErrorMessage); + } + _logger.LogError("Data import failed. Data package: {DataPath}", Path.GetFullPath(Data)); + return 1; + } + + _logger.LogInformation("Data import completed successfully."); + return 0; + } +} diff --git a/src/TALXIS.CLI.Data/TALXIS.CLI.Data.csproj b/src/TALXIS.CLI.Features.Data/TALXIS.CLI.Features.Data.csproj similarity index 68% rename from src/TALXIS.CLI.Data/TALXIS.CLI.Data.csproj rename to src/TALXIS.CLI.Features.Data/TALXIS.CLI.Features.Data.csproj index b6ec8a8..1fb059b 100644 --- a/src/TALXIS.CLI.Data/TALXIS.CLI.Data.csproj +++ b/src/TALXIS.CLI.Features.Data/TALXIS.CLI.Features.Data.csproj @@ -14,7 +14,9 @@ - + + + diff --git a/src/TALXIS.CLI.Data/TransformCliCommand.cs b/src/TALXIS.CLI.Features.Data/TransformCliCommand.cs similarity index 94% rename from src/TALXIS.CLI.Data/TransformCliCommand.cs rename to src/TALXIS.CLI.Features.Data/TransformCliCommand.cs index f23ad3c..dd054e4 100644 --- a/src/TALXIS.CLI.Data/TransformCliCommand.cs +++ b/src/TALXIS.CLI.Features.Data/TransformCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Data; +namespace TALXIS.CLI.Features.Data; [CliCommand( Description = "Data-related utilities for ETL, Power Query and migration scenarios" diff --git a/src/TALXIS.CLI.Data/TransformServerStartCliCommand.cs b/src/TALXIS.CLI.Features.Data/TransformServerStartCliCommand.cs similarity index 94% rename from src/TALXIS.CLI.Data/TransformServerStartCliCommand.cs rename to src/TALXIS.CLI.Features.Data/TransformServerStartCliCommand.cs index c859101..3cef223 100644 --- a/src/TALXIS.CLI.Data/TransformServerStartCliCommand.cs +++ b/src/TALXIS.CLI.Features.Data/TransformServerStartCliCommand.cs @@ -1,9 +1,11 @@ using DotMake.CommandLine; using Microsoft.Extensions.Logging; using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; -namespace TALXIS.CLI.Data; +namespace TALXIS.CLI.Features.Data; +[McpIgnore] [CliCommand( Name = "start", Description = "Starts a local HTTP server exposing endpoints for ETL/data transformation tasks. Useful for integrating with Power Query or other local ETL tools. " + diff --git a/src/TALXIS.CLI.Data/Transformation/ComputePrimaryKeyController.cs b/src/TALXIS.CLI.Features.Data/Transformation/ComputePrimaryKeyController.cs similarity index 98% rename from src/TALXIS.CLI.Data/Transformation/ComputePrimaryKeyController.cs rename to src/TALXIS.CLI.Features.Data/Transformation/ComputePrimaryKeyController.cs index 5e8b917..2416345 100644 --- a/src/TALXIS.CLI.Data/Transformation/ComputePrimaryKeyController.cs +++ b/src/TALXIS.CLI.Features.Data/Transformation/ComputePrimaryKeyController.cs @@ -3,7 +3,7 @@ using System.Text.Json; using System.Security.Cryptography; -namespace TALXIS.CLI.Data.DataServer; +namespace TALXIS.CLI.Features.Data.DataServer; public class ComputePrimaryKeyController { diff --git a/src/TALXIS.CLI.Data/Transformation/DataTransformationServer.cs b/src/TALXIS.CLI.Features.Data/Transformation/DataTransformationServer.cs similarity index 94% rename from src/TALXIS.CLI.Data/Transformation/DataTransformationServer.cs rename to src/TALXIS.CLI.Features.Data/Transformation/DataTransformationServer.cs index c39bb38..9b49597 100644 --- a/src/TALXIS.CLI.Data/Transformation/DataTransformationServer.cs +++ b/src/TALXIS.CLI.Features.Data/Transformation/DataTransformationServer.cs @@ -1,10 +1,10 @@ using System.Net; using Microsoft.Extensions.Logging; -using TALXIS.CLI.Data.DataServer; +using TALXIS.CLI.Features.Data.DataServer; using TALXIS.CLI.Logging; -using TALXIS.CLI.Shared; +using TALXIS.CLI.Core; -namespace TALXIS.CLI.Data; +namespace TALXIS.CLI.Features.Data; public class DataTransformationServer { diff --git a/src/TALXIS.CLI.Docs/DocsCliCommand.cs b/src/TALXIS.CLI.Features.Docs/DocsCliCommand.cs similarity index 86% rename from src/TALXIS.CLI.Docs/DocsCliCommand.cs rename to src/TALXIS.CLI.Features.Docs/DocsCliCommand.cs index c468943..91c6b7f 100644 --- a/src/TALXIS.CLI.Docs/DocsCliCommand.cs +++ b/src/TALXIS.CLI.Features.Docs/DocsCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Docs; +namespace TALXIS.CLI.Features.Docs; [CliCommand( Description = "Knowledge base for TALXIS CLI and building software with it" diff --git a/src/TALXIS.CLI.Docs/TALXIS.CLI.Docs.csproj b/src/TALXIS.CLI.Features.Docs/TALXIS.CLI.Features.Docs.csproj similarity index 100% rename from src/TALXIS.CLI.Docs/TALXIS.CLI.Docs.csproj rename to src/TALXIS.CLI.Features.Docs/TALXIS.CLI.Features.Docs.csproj diff --git a/src/TALXIS.CLI.Dataverse/AssemblyInfo.cs b/src/TALXIS.CLI.Features.Environment/AssemblyInfo.cs similarity index 100% rename from src/TALXIS.CLI.Dataverse/AssemblyInfo.cs rename to src/TALXIS.CLI.Features.Environment/AssemblyInfo.cs diff --git a/src/TALXIS.CLI.Environment/Deployment/DeploymentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Deployment/DeploymentCliCommand.cs similarity index 88% rename from src/TALXIS.CLI.Environment/Deployment/DeploymentCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/Deployment/DeploymentCliCommand.cs index 198c989..7adc853 100644 --- a/src/TALXIS.CLI.Environment/Deployment/DeploymentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Deployment/DeploymentCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Environment.Deployment; +namespace TALXIS.CLI.Features.Environment.Deployment; [CliCommand( Name = "deployment", diff --git a/src/TALXIS.CLI.Environment/Deployment/DeploymentListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Deployment/DeploymentListCliCommand.cs similarity index 72% rename from src/TALXIS.CLI.Environment/Deployment/DeploymentListCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/Deployment/DeploymentListCliCommand.cs index 8652e99..bf7e4e6 100644 --- a/src/TALXIS.CLI.Environment/Deployment/DeploymentListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Deployment/DeploymentListCliCommand.cs @@ -1,18 +1,20 @@ using System.Text.Json; using DotMake.CommandLine; using Microsoft.Extensions.Logging; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.Dataverse; using TALXIS.CLI.Logging; -using TALXIS.CLI.Shared; +using TALXIS.CLI.Core; -namespace TALXIS.CLI.Environment.Deployment; +namespace TALXIS.CLI.Features.Environment.Deployment; [CliCommand( Name = "list", Description = "List past deployment runs (package and solution) in the target environment." )] -public class DeploymentListCliCommand +public class DeploymentListCliCommand : ProfiledCliCommand { private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(DeploymentListCliCommand)); @@ -28,18 +30,6 @@ public class DeploymentListCliCommand [CliOption(Name = "--json", Description = "Emit the list as indented JSON instead of a text table.", Required = false)] public bool Json { get; set; } - [CliOption(Name = "--connection-string", Description = "Dataverse connection string. If omitted, txc checks DATAVERSE_CONNECTION_STRING and TXC_DATAVERSE_CONNECTION_STRING.", Required = false)] - public string? ConnectionString { get; set; } - - [CliOption(Name = "--environment", Description = "Dataverse environment URL for interactive sign-in when no connection string is provided.", Required = false)] - public string? EnvironmentUrl { get; set; } - - [CliOption(Name = "--device-code", Description = "Use Microsoft Entra device code flow instead of opening a browser for interactive sign-in.", Required = false)] - public bool DeviceCode { get; set; } - - [CliOption(Name = "--verbose", Description = "Enable verbose logging.", Required = false)] - public bool Verbose { get; set; } - public async Task RunAsync() { bool includePackages = true; @@ -76,51 +66,42 @@ public async Task RunAsync() defaultCount = 200; } - DataverseConnection conn; + DeploymentHistorySnapshot snapshot; try { - conn = ServiceClientFactory.Connect(ConnectionString, EnvironmentUrl, DeviceCode, Verbose, _logger); + var service = TxcServices.Get(); + snapshot = await service.GetRecentAsync( + Profile, + includePackages, + includeSolutions, + defaultCount, + sinceUtc, + Problems, + CancellationToken.None).ConfigureAwait(false); } - catch (InvalidOperationException ex) + catch (Exception ex) when (ex is ConfigurationResolutionException or InvalidOperationException or NotSupportedException) { _logger.LogError("{Error}", ex.Message); return 1; } + catch (Exception ex) + { + _logger.LogError(ex, "environment deployment list failed"); + return 1; + } - using (conn) + var rows = BuildRows(snapshot.Packages, snapshot.Solutions); + int max = sinceUtc is null ? 20 : rows.Count; + var trimmed = rows.Take(max).ToList(); + + if (Json) { - try - { - var pkgReader = new PackageHistoryReader(conn.Client, _logger); - var solReader = new SolutionHistoryReader(conn.Client, _logger); - - var pkgTask = includePackages - ? pkgReader.GetRecentAsync(defaultCount, sinceUtc, Problems) - : Task.FromResult>(Array.Empty()); - var solTask = includeSolutions - ? solReader.GetRecentAsync(defaultCount, sinceUtc, Problems) - : Task.FromResult>(Array.Empty()); - await Task.WhenAll(pkgTask, solTask).ConfigureAwait(false); - - var rows = BuildRows(await pkgTask.ConfigureAwait(false), await solTask.ConfigureAwait(false)); - int max = sinceUtc is null ? 20 : rows.Count; - var trimmed = rows.Take(max).ToList(); - - if (Json) - { - OutputWriter.WriteLine(JsonSerializer.Serialize(trimmed, JsonOptions)); - return 0; - } - - PrintRunsTable(trimmed); - return 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "environment deployment list failed"); - return 1; - } + OutputWriter.WriteLine(JsonSerializer.Serialize(trimmed, JsonOptions)); + return 0; } + + PrintRunsTable(trimmed); + return 0; } /// diff --git a/src/TALXIS.CLI.Environment/Deployment/DeploymentPatchCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Deployment/DeploymentPatchCliCommand.cs similarity index 96% rename from src/TALXIS.CLI.Environment/Deployment/DeploymentPatchCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/Deployment/DeploymentPatchCliCommand.cs index ab8e048..21938a7 100644 --- a/src/TALXIS.CLI.Environment/Deployment/DeploymentPatchCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Deployment/DeploymentPatchCliCommand.cs @@ -16,7 +16,7 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Environment.Deployment; +namespace TALXIS.CLI.Features.Environment.Deployment; [CliCommand( Description = "Reserved — not yet implemented.", diff --git a/src/TALXIS.CLI.Features.Environment/Deployment/DeploymentShowCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Deployment/DeploymentShowCliCommand.cs new file mode 100644 index 0000000..da49040 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Deployment/DeploymentShowCliCommand.cs @@ -0,0 +1,343 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Environment.Deployment; + +[CliCommand( + Name = "show", + Description = "Show details and findings for a single deployment run. Specify exactly one of --package-run-id, --solution-run-id, --async-operation-id, --package-name, --solution-name, or --latest." +)] +public class DeploymentShowCliCommand : ProfiledCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(DeploymentShowCliCommand)); + + [CliOption(Name = "--package-run-id", Description = "GUID of a package deployment run (packagehistory row).", Required = false)] + public string? PackageRunId { get; set; } + + [CliOption(Name = "--solution-run-id", Description = "GUID of a solution import run (msdyn_solutionhistory row).", Required = false)] + public string? SolutionRunId { get; set; } + + [CliOption(Name = "--async-operation-id", Description = "GUID of the async operation returned by a queued solution import. Falls back to the correlated solution history row, then to raw async-op status.", Required = false)] + public string? AsyncOperationId { get; set; } + + [CliOption(Name = "--package-name", Description = "NuGet package name returns the most recent run in packagehistory matching this name.", Required = false)] + public string? PackageName { get; set; } + + [CliOption(Name = "--solution-name", Description = "Solution unique name returns the most recent standalone solution import matching this name.", Required = false)] + public string? SolutionName { get; set; } + + [CliOption(Name = "--latest", Description = "Show the most recent deployment run across packages and solutions.", Required = false)] + public bool Latest { get; set; } + + [CliOption(Name = "--full", Description = "Include every correlated solution and the formatted import log (solution mode). Default output is compact.", Required = false)] + public bool Full { get; set; } + + [CliOption(Name = "--json", Description = "Emit the full structured record as indented JSON (always unbounded).", Required = false)] + public bool Json { get; set; } + + public async Task RunAsync() + { + int specified = + (PackageRunId is not null ? 1 : 0) + + (SolutionRunId is not null ? 1 : 0) + + (AsyncOperationId is not null ? 1 : 0) + + (PackageName is not null ? 1 : 0) + + (SolutionName is not null ? 1 : 0) + + (Latest ? 1 : 0); + + if (specified == 0) + { + _logger.LogError("Specify exactly one of --package-run-id, --solution-run-id, --async-operation-id, --package-name, --solution-name, or --latest."); + return 1; + } + if (specified > 1) + { + _logger.LogError("--package-run-id, --solution-run-id, --async-operation-id, --package-name, --solution-name, and --latest are mutually exclusive. Specify only one."); + return 1; + } + + Guid packageRunGuid = Guid.Empty, solutionRunGuid = Guid.Empty, asyncOpGuid = Guid.Empty; + if (PackageRunId is not null && !TryParseGuid(PackageRunId, "--package-run-id", out packageRunGuid)) return 1; + if (SolutionRunId is not null && !TryParseGuid(SolutionRunId, "--solution-run-id", out solutionRunGuid)) return 1; + if (AsyncOperationId is not null && !TryParseGuid(AsyncOperationId, "--async-operation-id", out asyncOpGuid)) return 1; + if (PackageName is not null && string.IsNullOrWhiteSpace(PackageName)) { _logger.LogError("--package-name must not be empty."); return 1; } + if (SolutionName is not null && string.IsNullOrWhiteSpace(SolutionName)) { _logger.LogError("--solution-name must not be empty."); return 1; } + + DeploymentDetailResult? result; + try + { + var service = TxcServices.Get(); + var ct = CancellationToken.None; + result = PackageRunId is not null ? await service.GetByPackageRunIdAsync(Profile, packageRunGuid, ct).ConfigureAwait(false) + : SolutionRunId is not null ? await service.GetBySolutionRunIdAsync(Profile, solutionRunGuid, Full, ct).ConfigureAwait(false) + : AsyncOperationId is not null ? await service.GetByAsyncOperationIdAsync(Profile, asyncOpGuid, Full, ct).ConfigureAwait(false) + : PackageName is not null ? await service.GetLatestByPackageNameAsync(Profile, PackageName!.Trim(), ct).ConfigureAwait(false) + : SolutionName is not null ? await service.GetLatestBySolutionNameAsync(Profile, SolutionName!.Trim(), Full, ct).ConfigureAwait(false) + : await service.GetLatestAsync(Profile, Full, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is ConfigurationResolutionException or InvalidOperationException or NotSupportedException) + { + _logger.LogError("{Error}", ex.Message); + return 1; + } + catch (Exception ex) + { + _logger.LogError(ex, "environment deployment show failed"); + return 1; + } + + if (result is null) + { + string hint = PackageRunId is not null ? $"No package run matched --package-run-id '{PackageRunId}'." + : SolutionRunId is not null ? $"No solution run matched --solution-run-id '{SolutionRunId}'." + : AsyncOperationId is not null ? $"No async operation or correlated solution run matched --async-operation-id '{AsyncOperationId}'." + : PackageName is not null ? $"No package run matched --package-name '{PackageName}'." + : SolutionName is not null ? $"No solution run matched --solution-name '{SolutionName}'." + : "No deployment runs found."; + _logger.LogError("{Message}", hint); + return 1; + } + + return result.Kind switch + { + DeploymentRunKind.Package => RenderPackage(result), + DeploymentRunKind.Solution => RenderSolution(result), + DeploymentRunKind.AsyncOperationInProgress => RenderAsyncInProgress(result), + DeploymentRunKind.AsyncOperationCompleted => RenderAsyncCompleted(result), + _ => 1, + }; + } + + private bool TryParseGuid(string value, string optionName, out Guid guid) + { + if (Guid.TryParse(value, out guid)) return true; + _logger.LogError("{Option} must be a full GUID.", optionName); + return false; + } + + private int RenderPackage(DeploymentDetailResult r) + { + var pkg = r.Package!; + var correlated = r.CorrelatedSolutions; + if (Json) + { + OutputWriter.WriteLine(JsonSerializer.Serialize(new + { + kind = "package", + id = pkg.Id, + name = pkg.Name, + status = pkg.Status, + stage = pkg.Stage, + startedAtUtc = pkg.StartedAtUtc?.ToString("O"), + completedAtUtc = pkg.CompletedAtUtc?.ToString("O"), + operationId = pkg.OperationId, + correlationId = pkg.CorrelationId, + message = pkg.Message, + solutions = correlated.Select(s => new + { + id = s.Id, + activityId = s.ActivityId, + solutionName = s.SolutionName, + solutionVersion = s.SolutionVersion, + operation = s.OperationLabel, + operationCode = s.OperationCode, + suboperation = s.SuboperationLabel, + suboperationCode = s.SuboperationCode, + overwriteUnmanagedCustomizations = s.OverwriteUnmanagedCustomizations, + startedAtUtc = s.StartedAtUtc?.ToString("O"), + completedAtUtc = s.CompletedAtUtc?.ToString("O"), + result = s.Result, + }).ToList(), + findings = r.Findings, + }, JsonOptions)); + return 0; + } + PrintPackage(pkg, correlated); + WriteFindings(r.Findings); + return 0; + } + + private int RenderSolution(DeploymentDetailResult r) + { + var sol = r.Solution!; + var parent = r.ParentPackage; + if (Json) + { + OutputWriter.WriteLine(JsonSerializer.Serialize(new + { + kind = "solution", + id = sol.Id, + solutionName = sol.SolutionName, + solutionVersion = sol.SolutionVersion, + packageName = sol.PackageName, + operation = sol.OperationLabel, + operationCode = sol.OperationCode, + suboperation = sol.SuboperationLabel, + suboperationCode = sol.SuboperationCode, + overwriteUnmanagedCustomizations = sol.OverwriteUnmanagedCustomizations, + startedAtUtc = sol.StartedAtUtc?.ToString("O"), + completedAtUtc = sol.CompletedAtUtc?.ToString("O"), + result = sol.Result, + parentPackage = parent is null ? null : new { id = parent.Id, name = parent.Name, status = parent.Status }, + formattedImportLog = r.FormattedImportLog, + findings = r.Findings, + }, JsonOptions)); + return 0; + } + PrintSolution(sol, parent); + if (Full && r.FormattedImportLog is not null) + { + OutputWriter.WriteLine(); + OutputWriter.WriteLine("-- formatted import log --"); + OutputWriter.WriteLine(r.FormattedImportLog); + } + WriteFindings(r.Findings); + return 0; + } + + private int RenderAsyncInProgress(DeploymentDetailResult r) + { + var op = r.AsyncOperation!; + if (Json) + { + OutputWriter.WriteLine(JsonSerializer.Serialize(new + { + kind = "asyncoperation", + id = op.Id, + state = op.StateLabel, + statecode = op.StateCode, + statuscode = op.StatusCode, + completed = false, + }, JsonOptions)); + } + else + { + OutputWriter.WriteLine($"Import in progress: {op.StateLabel}"); + OutputWriter.WriteLine($" asyncOperationId: {op.Id}"); + OutputWriter.WriteLine($" Run again to refresh or use `txc environment deployment show --async-operation-id {op.Id}` when done."); + } + return 0; + } + + private int RenderAsyncCompleted(DeploymentDetailResult r) + { + var op = r.AsyncOperation!; + if (Json) + { + OutputWriter.WriteLine(JsonSerializer.Serialize(new + { + kind = "asyncoperation", + id = op.Id, + state = "Completed", + statecode = op.StateCode, + statuscode = op.StatusCode, + completed = true, + succeeded = op.Succeeded, + message = op.Message, + }, JsonOptions)); + } + else + { + OutputWriter.WriteLine($"Async operation {op.Id}"); + OutputWriter.WriteLine($" state: Completed"); + OutputWriter.WriteLine($" result: {(op.Succeeded ? "Succeeded" : $"Failed (status {op.StatusCode})")}"); + if (!string.IsNullOrWhiteSpace(op.Message)) + { + OutputWriter.WriteLine($" message: {op.Message}"); + } + OutputWriter.WriteLine(" (Solution history record not yet available -- re-run shortly to get full details.)"); + } + return op.Succeeded ? 0 : 1; + } + + private static void PrintPackage(PackageHistoryRecord record, IReadOnlyList correlated) + { + OutputWriter.WriteLine($"Package: {record.Name ?? "(unknown)"}"); + OutputWriter.WriteLine($" id: {record.Id}"); + OutputWriter.WriteLine($" status: {record.Status ?? "(unknown)"}"); + bool completed = string.Equals(record.Status, "Completed", StringComparison.OrdinalIgnoreCase); + if (!completed && record.Stage is not null) + { + OutputWriter.WriteLine($" stage: {record.Stage}"); + } + OutputWriter.WriteLine($" started (UTC): {FormatUtc(record.StartedAtUtc)}"); + if (record.CompletedAtUtc is not null) + { + OutputWriter.WriteLine($" completed (UTC): {FormatUtc(record.CompletedAtUtc)}"); + } + if (record.StartedAtUtc is { } s && record.CompletedAtUtc is { } e) + { + OutputWriter.WriteLine($" duration: {FormatDuration(e - s)}"); + } + if (!string.IsNullOrWhiteSpace(record.Message)) + { + OutputWriter.WriteLine($" message: {record.Message}"); + } + + OutputWriter.WriteLine(); + OutputWriter.WriteLine($"Solutions within package run window: {correlated.Count}"); + if (correlated.Count == 0) return; + foreach (var solution in correlated) + { + string duration = (solution.StartedAtUtc is { } start && solution.CompletedAtUtc is { } end) + ? FormatDuration(end - start) + : "(unknown)"; + OutputWriter.WriteLine($" - {solution.SolutionName ?? "(unknown)"} | {solution.SuboperationLabel} | {duration}"); + } + } + + private static void PrintSolution(SolutionHistoryRecord record, PackageHistoryRecord? parent) + { + string context = parent is null + ? "(standalone import)" + : $"(part of package: {parent.Id.ToString("N")[..8]} {parent.Name})"; + + OutputWriter.WriteLine($"Solution: {record.SolutionName ?? "(unknown)"} {context}"); + OutputWriter.WriteLine($" id: {record.Id}"); + OutputWriter.WriteLine($" version: {record.SolutionVersion ?? "(unknown)"}"); + OutputWriter.WriteLine($" operation: {record.OperationLabel} / {record.SuboperationLabel}"); + if (record.OverwriteUnmanagedCustomizations is { } overwrite) + { + OutputWriter.WriteLine($" overwrite: {(overwrite ? "yes" : "no")}"); + } + OutputWriter.WriteLine($" started (UTC): {FormatUtc(record.StartedAtUtc)}"); + OutputWriter.WriteLine($" completed (UTC): {FormatUtc(record.CompletedAtUtc)}"); + if (record.StartedAtUtc is { } s && record.CompletedAtUtc is { } e) + { + OutputWriter.WriteLine($" duration: {FormatDuration(e - s)}"); + } + if (!string.IsNullOrWhiteSpace(record.Result)) + { + OutputWriter.WriteLine($" result: {record.Result}"); + } + } + + private static void WriteFindings(IReadOnlyList findings) + { + if (findings is null || findings.Count == 0) return; + OutputWriter.WriteLine(); + OutputWriter.WriteLine("Findings:"); + foreach (var f in findings) + { + OutputWriter.WriteLine($"- {f}"); + } + } + + private static string FormatUtc(DateTime? value) => value is null ? "(unknown)" : value.Value.ToString("O"); + + private static string FormatDuration(TimeSpan span) => span.TotalSeconds < 60 + ? $"{span.TotalSeconds:0.#}s" + : $"{(int)span.TotalMinutes}m {span.Seconds}s"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + }; +} diff --git a/src/TALXIS.CLI.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs similarity index 91% rename from src/TALXIS.CLI.Environment/EnvironmentCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index eaed1c5..0fad4f2 100644 --- a/src/TALXIS.CLI.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Environment; +namespace TALXIS.CLI.Features.Environment; [CliCommand( Name = "environment", diff --git a/src/TALXIS.CLI.Environment/Package/PackageCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Package/PackageCliCommand.cs similarity index 87% rename from src/TALXIS.CLI.Environment/Package/PackageCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/Package/PackageCliCommand.cs index 36e2d3c..980a3bd 100644 --- a/src/TALXIS.CLI.Environment/Package/PackageCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Package/PackageCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Environment.Package; +namespace TALXIS.CLI.Features.Environment.Package; [CliCommand( Name = "package", diff --git a/src/TALXIS.CLI.Features.Environment/Package/PackageImportCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Package/PackageImportCliCommand.cs new file mode 100644 index 0000000..2889f66 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Package/PackageImportCliCommand.cs @@ -0,0 +1,155 @@ +using System.ComponentModel; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Features.Config.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Core.Platforms.Packaging; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Package; + +[CliCommand( + Name = "import", + Description = "Import a deployable package into the target environment." +)] +public class PackageImportCliCommand : ProfiledCliCommand +{ + private readonly NuGetPackageInstallerService _packageInstaller = new(); + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(PackageImportCliCommand)); + + [CliArgument(Name = "package", Description = "NuGet package name, local .pdpkg.zip/.pdpkg/.zip archive path, or extracted package folder path.", Required = true)] + public required string Package { get; set; } + + [CliOption(Name = "--version", Description = "NuGet package version (only when 'package' is a NuGet name).", Required = false)] + [DefaultValue("latest")] + public string PackageVersion { get; set; } = "latest"; + + [CliOption(Name = "--output", Aliases = ["-o"], Description = "Download/extract output directory.", Required = false)] + public string? OutputDirectory { get; set; } + + [CliOption(Name = "--download-only", Description = "Download/extract without running Package Deployer.", Required = false)] + public bool DownloadOnly { get; set; } + + [CliOption(Name = "--settings", Description = "Runtime settings string for Package Deployer.", Required = false)] + public string? Settings { get; set; } + + [CliOption(Name = "--log-file", Description = "Path to Package Deployer log file.", Required = false)] + public string? LogFile { get; set; } + + [CliOption(Name = "--log-console", Description = "Enable Package Deployer console logging.", Required = false)] + public bool LogConsole { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(Package)) + { + _logger.LogError("'package' argument is required."); + return 1; + } + + bool isLocalFile = File.Exists(Package) + || Package.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) + || Package.EndsWith(".dll", StringComparison.OrdinalIgnoreCase); + + string packagePath; + string? tempWorkingDirectory = null; + string? nugetPackageName = null; + string? nugetPackageVersion = null; + + if (isLocalFile) + { + if (!File.Exists(Package)) + { + _logger.LogError("Package file not found: {PackagePath}", Package); + return 1; + } + + packagePath = Path.GetFullPath(Package); + _logger.LogInformation("Using local package: {PackagePath}", packagePath); + } + else + { + var options = new NuGetPackageInstallOptions(Package, PackageVersion, OutputDirectory); + var installResult = await _packageInstaller.InstallAsync(options); + + _logger.LogInformation("Resolved {PackageName} version {Version}", installResult.PackageName, installResult.ResolvedVersion); + _logger.LogInformation("Deployable package extracted to {Path}", installResult.DeployablePackagePath); + + nugetPackageName = installResult.PackageName; + nugetPackageVersion = installResult.ResolvedVersion; + + if (DownloadOnly) + { + return 0; + } + + packagePath = installResult.DeployablePackagePath; + if (installResult.UsesTemporaryWorkingDirectory) + { + tempWorkingDirectory = installResult.WorkingDirectory; + } + } + + PackageImportResult result; + try + { + var service = TxcServices.Get(); + result = await service.ImportAsync(new PackageImportRequest( + ProfileName: Profile, + PackagePath: packagePath, + Settings: Settings, + LogFile: LogFile, + LogConsole: LogConsole, + Verbose: Verbose, + NuGetPackageName: nugetPackageName, + NuGetPackageVersion: nugetPackageVersion, + TempWorkingDirectory: tempWorkingDirectory), CancellationToken.None).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Package import failed"); + _logger.LogError("Package located at {PackagePath}", packagePath); + return 1; + } + + if (result.InteractiveAuthRequired) + { + _logger.LogError("Interactive authentication is required. Run 'txc config auth login' for profile '{Profile}' and retry.", Profile ?? "(default)"); + return 1; + } + + if (!result.Succeeded) + { + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + { + _logger.LogError("{ErrorMessage}", result.ErrorMessage); + } + + if (!string.IsNullOrWhiteSpace(LogFile) && !string.IsNullOrWhiteSpace(result.LogFilePath)) + { + _logger.LogError("Detailed Package Deployer log: {LogPath}", result.LogFilePath); + } + + if (!string.IsNullOrWhiteSpace(LogFile) && !string.IsNullOrWhiteSpace(result.CmtLogFilePath)) + { + _logger.LogError("Detailed CMT import log: {LogPath}", result.CmtLogFilePath); + } + else if (string.IsNullOrWhiteSpace(LogFile) && + (!string.IsNullOrWhiteSpace(result.LogFilePath) || !string.IsNullOrWhiteSpace(result.CmtLogFilePath))) + { + _logger.LogWarning("Detailed temporary logs were cleaned up. Pass --log-file to preserve them."); + } + + _logger.LogError("Package import failed. Package located at {PackagePath}", packagePath); + return 1; + } + + _logger.LogInformation("Package import completed successfully."); + if (!string.IsNullOrWhiteSpace(LogFile)) + { + _logger.LogInformation("Package Deployer log: {LogPath}", Path.GetFullPath(LogFile)); + } + return 0; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Package/PackageUninstallCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Package/PackageUninstallCliCommand.cs new file mode 100644 index 0000000..3571c9f --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Package/PackageUninstallCliCommand.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Environment.Package; + +[CliCommand( + Name = "uninstall", + Description = "Uninstall all solutions belonging to a package from the target environment, in reverse import order." +)] +public class PackageUninstallCliCommand : ProfiledCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(PackageUninstallCliCommand)); + + [CliArgument(Name = "package", Description = "NuGet package name, local .pdpkg.zip/.pdpkg/.zip archive path, or extracted package folder path.", Required = true)] + public required string Package { get; set; } + + [CliOption(Name = "--version", Description = "NuGet package version when 'package' is a NuGet name. Defaults to 'latest'.", Required = false)] + public string PackageVersion { get; set; } = "latest"; + + [CliOption(Name = "--output", Aliases = ["-o"], Description = "Directory for temporary/downloaded package assets when resolving from NuGet.", Required = false)] + public string? OutputDirectory { get; set; } + + [CliOption(Name = "--yes", Description = "Confirm destructive uninstall actions.", Required = false)] + public bool Yes { get; set; } + + [CliOption(Name = "--json", Description = "Emit uninstall result as JSON.", Required = false)] + public bool Json { get; set; } + + public async Task RunAsync() + { + if (!Yes) + { + _logger.LogError("Uninstall is destructive. Pass --yes to confirm."); + return 1; + } + + if (string.IsNullOrWhiteSpace(Package)) + { + _logger.LogError("'package' argument is required."); + return 1; + } + + PackageUninstallResult result; + try + { + var service = TxcServices.Get(); + result = await service.UninstallAsync(new PackageUninstallRequest( + ProfileName: Profile, + PackageSource: Package, + PackageVersion: PackageVersion, + OutputDirectory: OutputDirectory), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) when (ex is ConfigurationResolutionException or InvalidOperationException or NotSupportedException) + { + _logger.LogError("{Error}", ex.Message); + return 1; + } + catch (Exception ex) + { + _logger.LogError(ex, "environment package uninstall failed"); + return 1; + } + + if (result.UninstallOrder.Count == 0) + { + _logger.LogError("No uninstallable solutions were resolved from package '{Source}'.", Package); + return 1; + } + + if (Json) + { + OutputWriter.WriteLine(JsonSerializer.Serialize(new + { + mode = "package", + package = Package, + packageName = result.PackageDisplayName, + solutionCount = result.UninstallOrder.Count, + uninstallOrder = result.UninstallOrder, + outcomes = result.Outcomes, + }, JsonOptions)); + } + else + { + OutputWriter.WriteLine($"Package: {result.PackageDisplayName}"); + OutputWriter.WriteLine($"Source: {Package}"); + OutputWriter.WriteLine($"Resolved solutions: {result.UninstallOrder.Count}"); + OutputWriter.WriteLine("Uninstall order (reverse ImportConfig):"); + foreach (var name in result.UninstallOrder) + { + OutputWriter.WriteLine($" - {name}"); + } + foreach (var outcome in result.Outcomes) + { + OutputWriter.WriteLine($"- {outcome.SolutionName}: {outcome.Status} ({outcome.Message})"); + } + } + + return result.Outcomes.All(o => o.Status == SolutionUninstallStatus.Success) ? 0 : 1; + } + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + }; + + /// + /// Pure helper kept for test coverage: derives the reverse uninstall order from an + /// import-order list (trim, distinct, reverse). The service uses equivalent logic. + /// + public static IReadOnlyList BuildReverseUninstallOrderFromImportConfig(IReadOnlyList importOrderSolutionNames) + { + ArgumentNullException.ThrowIfNull(importOrderSolutionNames); + + var ordered = importOrderSolutionNames + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Select(n => n.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + ordered.Reverse(); + return ordered; + } +} diff --git a/src/TALXIS.CLI.Environment/Solution/SolutionCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionCliCommand.cs similarity index 88% rename from src/TALXIS.CLI.Environment/Solution/SolutionCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/Solution/SolutionCliCommand.cs index 617ec14..8b54715 100644 --- a/src/TALXIS.CLI.Environment/Solution/SolutionCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Environment.Solution; +namespace TALXIS.CLI.Features.Environment.Solution; [CliCommand( Name = "solution", diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs new file mode 100644 index 0000000..044d1ac --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionImportCliCommand.cs @@ -0,0 +1,130 @@ +using System.ComponentModel; +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.Features.Environment.Solution; + +[CliCommand( + Name = "import", + Description = "Import a solution .zip into the target environment." +)] +public class SolutionImportCliCommand : ProfiledCliCommand +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(SolutionImportCliCommand)); + + [CliArgument(Name = "solution-zip", Description = "Path to the solution .zip to import.", Required = true)] + public required string SolutionZip { get; set; } + + [CliOption(Name = "--stage-and-upgrade", Description = "Use single-step upgrade when applicable.", Required = false)] + [DefaultValue(true)] + public bool StageAndUpgrade { get; set; } = true; + + [CliOption(Name = "--force-overwrite", Description = "Overwrite unmanaged customizations (disables SmartDiff).", Required = false)] + public bool ForceOverwrite { get; set; } + + [CliOption(Name = "--publish-workflows", Description = "Activate workflows after import.", Required = false)] + public bool PublishWorkflows { get; set; } + + [CliOption(Name = "--skip-dependency-check", Description = "Skip product-update dependency checks.", Required = false)] + public bool SkipDependencyCheck { get; set; } + + [CliOption(Name = "--skip-lower-version", Description = "Skip import when source version is not higher than target.", Required = false)] + public bool SkipLowerVersion { get; set; } + + [CliOption(Name = "--wait", Description = "Wait for completion. By default solution imports return after queueing.", Required = false)] + public bool Wait { get; set; } + + [CliOption(Name = "--json", Description = "Emit import result as JSON.", Required = false)] + public bool Json { get; set; } + + public async Task RunAsync() + { + if (string.IsNullOrWhiteSpace(SolutionZip)) + { + _logger.LogError("'solution-zip' argument is required."); + return 1; + } + + string solutionPath = Path.GetFullPath(SolutionZip); + if (!File.Exists(solutionPath)) + { + _logger.LogError("Solution file not found: {Path}", solutionPath); + return 1; + } + + var options = new SolutionImportOptions( + StageAndUpgrade: StageAndUpgrade, + ForceOverwrite: ForceOverwrite, + PublishWorkflows: PublishWorkflows, + SkipDependencyCheck: SkipDependencyCheck, + SkipLowerVersion: SkipLowerVersion, + Async: !Wait); + + SolutionImportResult result; + try + { + var service = TxcServices.Get(); + result = await service.ImportAsync(Profile, solutionPath, options, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) when (ex is ConfigurationResolutionException or InvalidOperationException or NotSupportedException or FileNotFoundException) + { + _logger.LogError("{Error}", ex.Message); + return 1; + } + catch (Exception ex) + { + _logger.LogError(ex, "Solution import failed"); + return 1; + } + + if (Json) + { + var payload = new + { + path = FormatPath(result.Path), + uniqueName = result.Source.UniqueName, + sourceVersion = result.Source.Version.ToString(), + sourceManaged = result.Source.Managed, + existingVersion = result.ExistingTarget?.Version.ToString(), + existingManaged = result.ExistingTarget?.Managed, + importJobId = result.ImportJobId, + asyncOperationId = result.AsyncOperationId, + startedAtUtc = result.StartedAtUtc.ToString("O"), + completedAtUtc = result.CompletedAtUtc?.ToString("O"), + smartDiffExpected = result.SmartDiffExpected, + status = result.Status, + }; + OutputWriter.WriteLine(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); + } + + _logger.LogInformation("Import path: {Path}", FormatPath(result.Path)); + _logger.LogInformation("Status: {Status}", result.Status); + _logger.LogInformation("ImportJobId: {ImportJobId}", result.ImportJobId); + if (result.AsyncOperationId is { } asyncId) + { + _logger.LogInformation("AsyncOperationId: {AsyncOperationId}", asyncId); + } + _logger.LogInformation("Started (UTC): {Start}", result.StartedAtUtc.ToString("O")); + if (result.CompletedAtUtc is { } completed) + { + _logger.LogInformation("Completed (UTC): {End}", completed.ToString("O")); + } + + return 0; + } + + private static string FormatPath(SolutionImportPath path) => path switch + { + SolutionImportPath.Install => "install", + SolutionImportPath.Update => "update", + SolutionImportPath.Upgrade => "single-step upgrade", + _ => path.ToString() + }; +} diff --git a/src/TALXIS.CLI.Environment/Solution/SolutionListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionListCliCommand.cs similarity index 59% rename from src/TALXIS.CLI.Environment/Solution/SolutionListCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/Solution/SolutionListCliCommand.cs index 471df25..eeddc28 100644 --- a/src/TALXIS.CLI.Environment/Solution/SolutionListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionListCliCommand.cs @@ -1,18 +1,20 @@ using System.Text.Json; using DotMake.CommandLine; using Microsoft.Extensions.Logging; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.Dataverse; using TALXIS.CLI.Logging; -using TALXIS.CLI.Shared; +using TALXIS.CLI.Core; -namespace TALXIS.CLI.Environment.Solution; +namespace TALXIS.CLI.Features.Environment.Solution; [CliCommand( Name = "list", Description = "List installed solutions in the target environment." )] -public class SolutionListCliCommand +public class SolutionListCliCommand : ProfiledCliCommand { private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(SolutionListCliCommand)); @@ -22,18 +24,6 @@ public class SolutionListCliCommand [CliOption(Name = "--json", Description = "Emit the list as indented JSON instead of a text table.", Required = false)] public bool Json { get; set; } - [CliOption(Name = "--connection-string", Description = "Dataverse connection string. If omitted, txc checks DATAVERSE_CONNECTION_STRING and TXC_DATAVERSE_CONNECTION_STRING.", Required = false)] - public string? ConnectionString { get; set; } - - [CliOption(Name = "--environment", Description = "Dataverse environment URL for interactive sign-in when no connection string is provided.", Required = false)] - public string? EnvironmentUrl { get; set; } - - [CliOption(Name = "--device-code", Description = "Use Microsoft Entra device code flow instead of opening a browser for interactive sign-in.", Required = false)] - public bool DeviceCode { get; set; } - - [CliOption(Name = "--verbose", Description = "Enable verbose logging.", Required = false)] - public bool Verbose { get; set; } - public async Task RunAsync() { bool? managedFilter = null; @@ -47,39 +37,31 @@ public async Task RunAsync() managedFilter = parsedManaged; } - DataverseConnection conn; + IReadOnlyList rows; try { - conn = ServiceClientFactory.Connect(ConnectionString, EnvironmentUrl, DeviceCode, Verbose, _logger); + var service = TxcServices.Get(); + rows = await service.ListAsync(Profile, managedFilter, CancellationToken.None).ConfigureAwait(false); } - catch (InvalidOperationException ex) + catch (Exception ex) when (ex is ConfigurationResolutionException or InvalidOperationException or NotSupportedException) { _logger.LogError("{Error}", ex.Message); return 1; } - - using (conn) + catch (Exception ex) { - try - { - var reader = new SolutionInventoryReader(conn.Client); - var rows = await reader.ListAsync(managedFilter).ConfigureAwait(false); - - if (Json) - { - OutputWriter.WriteLine(JsonSerializer.Serialize(rows, JsonOptions)); - return 0; - } + _logger.LogError(ex, "environment solution list failed"); + return 1; + } - PrintSolutionsTable(rows); - return 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "environment solution list failed"); - return 1; - } + if (Json) + { + OutputWriter.WriteLine(JsonSerializer.Serialize(rows, JsonOptions)); + return 0; } + + PrintSolutionsTable(rows); + return 0; } private static void PrintSolutionsTable(IReadOnlyList rows) diff --git a/src/TALXIS.CLI.Environment/Solution/SolutionUninstallCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionUninstallCliCommand.cs similarity index 55% rename from src/TALXIS.CLI.Environment/Solution/SolutionUninstallCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/Solution/SolutionUninstallCliCommand.cs index 2f7eb30..67c2a40 100644 --- a/src/TALXIS.CLI.Environment/Solution/SolutionUninstallCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionUninstallCliCommand.cs @@ -1,18 +1,20 @@ using System.Text.Json; using DotMake.CommandLine; using Microsoft.Extensions.Logging; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Platforms.Dataverse; using TALXIS.CLI.Logging; -using TALXIS.CLI.Shared; +using TALXIS.CLI.Core; -namespace TALXIS.CLI.Environment.Solution; +namespace TALXIS.CLI.Features.Environment.Solution; [CliCommand( Name = "uninstall", Description = "Uninstall a single solution by unique name from the target environment." )] -public class SolutionUninstallCliCommand +public class SolutionUninstallCliCommand : ProfiledCliCommand { private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(SolutionUninstallCliCommand)); @@ -22,21 +24,9 @@ public class SolutionUninstallCliCommand [CliOption(Name = "--yes", Description = "Confirm destructive uninstall action.", Required = false)] public bool Yes { get; set; } - [CliOption(Name = "--connection-string", Description = "Dataverse connection string. If omitted, txc checks DATAVERSE_CONNECTION_STRING and TXC_DATAVERSE_CONNECTION_STRING.", Required = false)] - public string? ConnectionString { get; set; } - - [CliOption(Name = "--environment", Description = "Dataverse environment URL for interactive sign-in when no connection string is provided.", Required = false)] - public string? EnvironmentUrl { get; set; } - - [CliOption(Name = "--device-code", Description = "Use Microsoft Entra device code flow instead of opening a browser for interactive sign-in.", Required = false)] - public bool DeviceCode { get; set; } - [CliOption(Name = "--json", Description = "Emit uninstall result as JSON.", Required = false)] public bool Json { get; set; } - [CliOption(Name = "--verbose", Description = "Enable verbose logging.", Required = false)] - public bool Verbose { get; set; } - public async Task RunAsync() { if (!Yes) @@ -51,32 +41,24 @@ public async Task RunAsync() return 1; } - DataverseConnection conn; + SolutionUninstallOutcome outcome; try { - conn = ServiceClientFactory.Connect(ConnectionString, EnvironmentUrl, DeviceCode, Verbose, _logger); + var service = TxcServices.Get(); + outcome = await service.UninstallByUniqueNameAsync(Profile, Name, CancellationToken.None).ConfigureAwait(false); } - catch (InvalidOperationException ex) + catch (Exception ex) when (ex is ConfigurationResolutionException or InvalidOperationException or NotSupportedException) { _logger.LogError("{Error}", ex.Message); return 1; } - - using (conn) + catch (Exception ex) { - var client = conn.Client; - try - { - var uninstaller = new SolutionUninstaller(client, _logger); - var outcome = await uninstaller.UninstallByUniqueNameAsync(Name).ConfigureAwait(false); - return RenderSingle(outcome); - } - catch (Exception ex) - { - _logger.LogError(ex, "environment solution uninstall failed"); - return 1; - } + _logger.LogError(ex, "environment solution uninstall failed"); + return 1; } + + return RenderSingle(outcome); } private int RenderSingle(SolutionUninstallOutcome outcome) diff --git a/src/TALXIS.CLI.Dataverse/TALXIS.CLI.Dataverse.csproj b/src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj similarity index 56% rename from src/TALXIS.CLI.Dataverse/TALXIS.CLI.Dataverse.csproj rename to src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj index c5f0142..92ec382 100644 --- a/src/TALXIS.CLI.Dataverse/TALXIS.CLI.Dataverse.csproj +++ b/src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj @@ -7,12 +7,12 @@ - - - + + + diff --git a/src/TALXIS.CLI.Workspace/ComponentCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs similarity index 96% rename from src/TALXIS.CLI.Workspace/ComponentCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs index 621574b..3d4c70f 100644 --- a/src/TALXIS.CLI.Workspace/ComponentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Workspace; +namespace TALXIS.CLI.Features.Workspace; [CliCommand( Description = "Create or modify components of your solution", diff --git a/src/TALXIS.CLI.Workspace/ComponentCreateCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentCreateCliCommand.cs similarity index 97% rename from src/TALXIS.CLI.Workspace/ComponentCreateCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/ComponentCreateCliCommand.cs index 4438a78..61e1d52 100644 --- a/src/TALXIS.CLI.Workspace/ComponentCreateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ComponentCreateCliCommand.cs @@ -2,9 +2,9 @@ using DotMake.CommandLine; using Microsoft.Extensions.Logging; using TALXIS.CLI.Logging; -using TALXIS.CLI.Shared; -using TALXIS.CLI.Workspace.TemplateEngine; -namespace TALXIS.CLI.Workspace; +using TALXIS.CLI.Core; +using TALXIS.CLI.Features.Workspace.TemplateEngine; +namespace TALXIS.CLI.Features.Workspace; [CliCommand( Description = "Scaffolds a component from a template and passes parameters", diff --git a/src/TALXIS.CLI.Workspace/ComponentParameterListCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs similarity index 95% rename from src/TALXIS.CLI.Workspace/ComponentParameterListCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs index 826d04b..1d5440e 100644 --- a/src/TALXIS.CLI.Workspace/ComponentParameterListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs @@ -1,10 +1,10 @@ using System.Text; using DotMake.CommandLine; using Microsoft.TemplateEngine.Abstractions; -using TALXIS.CLI.Shared; -using TALXIS.CLI.Workspace.TemplateEngine; +using TALXIS.CLI.Core; +using TALXIS.CLI.Features.Workspace.TemplateEngine; -namespace TALXIS.CLI.Workspace; +namespace TALXIS.CLI.Features.Workspace; /// /// CLI command to list parameters required for a specific component template. diff --git a/src/TALXIS.CLI.Workspace/ComponentTypeExplainCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentTypeExplainCliCommand.cs similarity index 92% rename from src/TALXIS.CLI.Workspace/ComponentTypeExplainCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/ComponentTypeExplainCliCommand.cs index 7fb906b..40029de 100644 --- a/src/TALXIS.CLI.Workspace/ComponentTypeExplainCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ComponentTypeExplainCliCommand.cs @@ -1,10 +1,10 @@ using DotMake.CommandLine; using Microsoft.Extensions.Logging; using TALXIS.CLI.Logging; -using TALXIS.CLI.Shared; -using TALXIS.CLI.Workspace.TemplateEngine; +using TALXIS.CLI.Core; +using TALXIS.CLI.Features.Workspace.TemplateEngine; -namespace TALXIS.CLI.Workspace; +namespace TALXIS.CLI.Features.Workspace; [CliCommand( Description = "Explains a solution component type. Use names returned by 'component type list' command", diff --git a/src/TALXIS.CLI.Workspace/ComponentTypeListCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentTypeListCliCommand.cs similarity index 87% rename from src/TALXIS.CLI.Workspace/ComponentTypeListCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/ComponentTypeListCliCommand.cs index 97003a0..de37bd7 100644 --- a/src/TALXIS.CLI.Workspace/ComponentTypeListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ComponentTypeListCliCommand.cs @@ -1,8 +1,8 @@ using DotMake.CommandLine; -using TALXIS.CLI.Shared; -using TALXIS.CLI.Workspace.TemplateEngine; +using TALXIS.CLI.Core; +using TALXIS.CLI.Features.Workspace.TemplateEngine; -namespace TALXIS.CLI.Workspace; +namespace TALXIS.CLI.Features.Workspace; [CliCommand( Description = "Lists available solution components", diff --git a/src/TALXIS.CLI.Workspace/Metamodel/MetamodelCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelCliCommand.cs similarity index 96% rename from src/TALXIS.CLI.Workspace/Metamodel/MetamodelCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelCliCommand.cs index c547637..d392278 100644 --- a/src/TALXIS.CLI.Workspace/Metamodel/MetamodelCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelCliCommand.cs @@ -17,7 +17,7 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Workspace.Metamodel; +namespace TALXIS.CLI.Features.Workspace.Metamodel; [CliCommand( Description = "Reserved — not yet implemented.", diff --git a/src/TALXIS.CLI.Workspace/Metamodel/MetamodelDescribeCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelDescribeCliCommand.cs similarity index 94% rename from src/TALXIS.CLI.Workspace/Metamodel/MetamodelDescribeCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelDescribeCliCommand.cs index 56bab1a..d31a906 100644 --- a/src/TALXIS.CLI.Workspace/Metamodel/MetamodelDescribeCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelDescribeCliCommand.cs @@ -9,7 +9,7 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Workspace.Metamodel; +namespace TALXIS.CLI.Features.Workspace.Metamodel; [CliCommand( Description = "Reserved — not yet implemented.", diff --git a/src/TALXIS.CLI.Workspace/Metamodel/MetamodelListCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelListCliCommand.cs similarity index 94% rename from src/TALXIS.CLI.Workspace/Metamodel/MetamodelListCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelListCliCommand.cs index 60f4558..b34ac36 100644 --- a/src/TALXIS.CLI.Workspace/Metamodel/MetamodelListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/Metamodel/MetamodelListCliCommand.cs @@ -9,7 +9,7 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Workspace.Metamodel; +namespace TALXIS.CLI.Features.Workspace.Metamodel; [CliCommand( Description = "Reserved — not yet implemented.", diff --git a/src/TALXIS.CLI.Workspace/ProjectCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ProjectCliCommand.cs similarity index 97% rename from src/TALXIS.CLI.Workspace/ProjectCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/ProjectCliCommand.cs index 93485fb..b8d2f1d 100644 --- a/src/TALXIS.CLI.Workspace/ProjectCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ProjectCliCommand.cs @@ -1,7 +1,7 @@ using DotMake.CommandLine; -using TALXIS.CLI.Shared; +using TALXIS.CLI.Core; -namespace TALXIS.CLI.Workspace; +namespace TALXIS.CLI.Features.Workspace; [CliCommand( Description = "Work with MSBuild projects in your workspace (solutions, plugins, libraries, controls...)", diff --git a/src/TALXIS.CLI.Workspace/TALXIS.CLI.Workspace.csproj b/src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj similarity index 83% rename from src/TALXIS.CLI.Workspace/TALXIS.CLI.Workspace.csproj rename to src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj index 8e48804..52fdfb1 100644 --- a/src/TALXIS.CLI.Workspace/TALXIS.CLI.Workspace.csproj +++ b/src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj @@ -7,7 +7,8 @@ - + + diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/AddProjectsToSlnPostActionProcessor.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/AddProjectsToSlnPostActionProcessor.cs similarity index 97% rename from src/TALXIS.CLI.Workspace/TemplateEngine/AddProjectsToSlnPostActionProcessor.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/AddProjectsToSlnPostActionProcessor.cs index 84a1357..ad8b887 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/AddProjectsToSlnPostActionProcessor.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/AddProjectsToSlnPostActionProcessor.cs @@ -3,7 +3,7 @@ using Microsoft.TemplateEngine.Edge.Template; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// Post-action processor that adds project files to a solution file. @@ -16,8 +16,8 @@ public class AddProjectsToSlnPostActionProcessor : IPostActionProcessor public bool Process(IEngineEnvironmentSettings environment, IPostAction action) { - // Fall back to using Environment.CurrentDirectory if no explicit output path is provided - return ProcessInternal(environment, action, null!, null!, Environment.CurrentDirectory); + // Fall back to using System.Environment.CurrentDirectory if no explicit output path is provided + return ProcessInternal(environment, action, null!, null!, System.Environment.CurrentDirectory); } /// diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/AddReferencePostActionProcessor.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/AddReferencePostActionProcessor.cs similarity index 93% rename from src/TALXIS.CLI.Workspace/TemplateEngine/AddReferencePostActionProcessor.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/AddReferencePostActionProcessor.cs index 515a6ea..03d1dc3 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/AddReferencePostActionProcessor.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/AddReferencePostActionProcessor.cs @@ -3,7 +3,7 @@ using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { public class AddReferencePostActionProcessor : IPostActionProcessor { @@ -27,7 +27,7 @@ public bool Process(IEngineEnvironmentSettings environment, IPostAction action) { FileName = "dotnet", Arguments = $"add \"{projectFile}\" reference \"{referenceFile}\"", - WorkingDirectory = Environment.CurrentDirectory, + WorkingDirectory = System.Environment.CurrentDirectory, // If environment is not null and you want to use a custom path, update here RedirectStandardOutput = true, RedirectStandardError = true, diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/IPostActionProcessor.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/IPostActionProcessor.cs similarity index 97% rename from src/TALXIS.CLI.Workspace/TemplateEngine/IPostActionProcessor.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/IPostActionProcessor.cs index 7d946e4..a276ef5 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/IPostActionProcessor.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/IPostActionProcessor.cs @@ -1,6 +1,6 @@ using Microsoft.TemplateEngine.Abstractions; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// The interface defining the post action processor supported by txc CLI. diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/PostActionDispatcher.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/PostActionDispatcher.cs similarity index 98% rename from src/TALXIS.CLI.Workspace/TemplateEngine/PostActionDispatcher.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/PostActionDispatcher.cs index 9aec406..7bfc4ab 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/PostActionDispatcher.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/PostActionDispatcher.cs @@ -5,7 +5,7 @@ using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { public class PostActionDispatcher { diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/RunScriptPostActionProcessor.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/RunScriptPostActionProcessor.cs similarity index 96% rename from src/TALXIS.CLI.Workspace/TemplateEngine/RunScriptPostActionProcessor.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/RunScriptPostActionProcessor.cs index e1d7bb8..40f44b4 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/RunScriptPostActionProcessor.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/RunScriptPostActionProcessor.cs @@ -4,7 +4,7 @@ using Microsoft.TemplateEngine.Abstractions.PhysicalFileSystem; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// Post-action processor for running PowerShell and other scripts during template processing. @@ -19,8 +19,8 @@ public class RunScriptPostActionProcessor : IPostActionProcessor public bool Process(IEngineEnvironmentSettings environment, IPostAction action) { - // Fall back to using Environment.CurrentDirectory if no explicit output path is provided - return ProcessInternal(environment, action, null!, null!, Environment.CurrentDirectory); + // Fall back to using System.Environment.CurrentDirectory if no explicit output path is provided + return ProcessInternal(environment, action, null!, null!, System.Environment.CurrentDirectory); } public bool ProcessInternal(IEngineEnvironmentSettings environment, IPostAction action, ICreationEffects creationEffects, ICreationResult? templateCreationResult, string outputBasePath) @@ -40,7 +40,7 @@ public bool ProcessInternal(IEngineEnvironmentSettings environment, IPostAction var scriptArgs = args.TryGetValue("args", out var scriptArgsValue) ? scriptArgsValue : string.Empty; - // Use the explicit outputBasePath as working directory instead of Environment.CurrentDirectory + // Use the explicit outputBasePath as working directory instead of System.Environment.CurrentDirectory // This ensures consistent behavior regardless of any directory changes by previous operations string workingDir = outputBasePath; diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/TalxisCliTemplateEngineHost.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TalxisCliTemplateEngineHost.cs similarity index 97% rename from src/TALXIS.CLI.Workspace/TemplateEngine/TalxisCliTemplateEngineHost.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/TalxisCliTemplateEngineHost.cs index 3aa6949..1129f3b 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/TalxisCliTemplateEngineHost.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TalxisCliTemplateEngineHost.cs @@ -3,7 +3,7 @@ using Microsoft.TemplateEngine.Abstractions; using Microsoft.TemplateEngine.Edge; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// Custom template engine host for TALXIS CLI that follows the same patterns as the official dotnet CLI. diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateCreationService.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateCreationService.cs similarity index 99% rename from src/TALXIS.CLI.Workspace/TemplateEngine/TemplateCreationService.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateCreationService.cs index 3d46228..9f08596 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateCreationService.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateCreationService.cs @@ -6,7 +6,7 @@ using CreationResultStatus = Microsoft.TemplateEngine.Edge.Template.CreationResultStatus; using ITemplateCreationResult = Microsoft.TemplateEngine.Edge.Template.ITemplateCreationResult; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// Service responsible for creating templates from template definitions. diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateDiscoveryService.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateDiscoveryService.cs similarity index 97% rename from src/TALXIS.CLI.Workspace/TemplateEngine/TemplateDiscoveryService.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateDiscoveryService.cs index 9d03e96..c072451 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateDiscoveryService.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateDiscoveryService.cs @@ -1,6 +1,6 @@ using Microsoft.TemplateEngine.Abstractions; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// Service responsible for discovering and retrieving templates. diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateEngineFactory.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateEngineFactory.cs similarity index 97% rename from src/TALXIS.CLI.Workspace/TemplateEngine/TemplateEngineFactory.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateEngineFactory.cs index 6320c55..90ad549 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateEngineFactory.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateEngineFactory.cs @@ -4,7 +4,7 @@ using Microsoft.TemplateEngine.Edge.Settings; using Microsoft.TemplateEngine.Edge.Template; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// Factory for creating and configuring template engine services. @@ -86,7 +86,7 @@ private static TalxisCliTemplateEngineHost CreateTemplateEngineHost( builtIns.AddRange(Microsoft.TemplateEngine.Edge.Components.AllComponents); return new TalxisCliTemplateEngineHost( - hostIdentifier: "TALXIS.CLI.Workspace", + hostIdentifier: "TALXIS.CLI.Features.Workspace", version: version, preferences: new Dictionary { diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateInvoker.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateInvoker.cs similarity index 98% rename from src/TALXIS.CLI.Workspace/TemplateEngine/TemplateInvoker.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateInvoker.cs index 99a916a..301c250 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateInvoker.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateInvoker.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.TemplateEngine.Abstractions; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// Template invoker that uses the new service-based architecture for template engine operations. diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplatePackageService.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplatePackageService.cs similarity index 99% rename from src/TALXIS.CLI.Workspace/TemplateEngine/TemplatePackageService.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplatePackageService.cs index 5726d3d..d04ce9b 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplatePackageService.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplatePackageService.cs @@ -6,7 +6,7 @@ using System.Security.Cryptography; using System.Diagnostics; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// Manages the TALXIS template package ensuring a single installation across processes. diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateParameterValidator.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterValidator.cs similarity index 98% rename from src/TALXIS.CLI.Workspace/TemplateEngine/TemplateParameterValidator.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterValidator.cs index 403fd13..0da772c 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateParameterValidator.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterValidator.cs @@ -2,7 +2,7 @@ using Microsoft.TemplateEngine.Abstractions; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// Service responsible for validating template parameters. diff --git a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateScaffoldResult.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateScaffoldResult.cs similarity index 84% rename from src/TALXIS.CLI.Workspace/TemplateEngine/TemplateScaffoldResult.cs rename to src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateScaffoldResult.cs index ec322c4..3559bfd 100644 --- a/src/TALXIS.CLI.Workspace/TemplateEngine/TemplateScaffoldResult.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateScaffoldResult.cs @@ -1,6 +1,6 @@ using Microsoft.TemplateEngine.Abstractions; -namespace TALXIS.CLI.Workspace.TemplateEngine +namespace TALXIS.CLI.Features.Workspace.TemplateEngine { /// /// Result of template scaffolding operation. diff --git a/src/TALXIS.CLI.Workspace/WorkspaceCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs similarity index 97% rename from src/TALXIS.CLI.Workspace/WorkspaceCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs index 2b7cf8d..1c1b12f 100644 --- a/src/TALXIS.CLI.Workspace/WorkspaceCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs @@ -1,7 +1,7 @@ using DotMake.CommandLine; -using TALXIS.CLI.Shared; +using TALXIS.CLI.Core; -namespace TALXIS.CLI.Workspace; +namespace TALXIS.CLI.Features.Workspace; [CliCommand( Description = "Implement software in your local computer workspace (Git repository)", diff --git a/src/TALXIS.CLI.Workspace/WorkspaceLanguageServerCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/WorkspaceLanguageServerCliCommand.cs similarity index 96% rename from src/TALXIS.CLI.Workspace/WorkspaceLanguageServerCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/WorkspaceLanguageServerCliCommand.cs index dde5d74..3326ff4 100644 --- a/src/TALXIS.CLI.Workspace/WorkspaceLanguageServerCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/WorkspaceLanguageServerCliCommand.cs @@ -15,7 +15,7 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Workspace; +namespace TALXIS.CLI.Features.Workspace; [CliCommand( Description = "Reserved — not yet implemented.", diff --git a/src/TALXIS.CLI.Workspace/WorkspaceValidateCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/WorkspaceValidateCliCommand.cs similarity index 96% rename from src/TALXIS.CLI.Workspace/WorkspaceValidateCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/WorkspaceValidateCliCommand.cs index 36dad0c..3968d2c 100644 --- a/src/TALXIS.CLI.Workspace/WorkspaceValidateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/WorkspaceValidateCliCommand.cs @@ -15,7 +15,7 @@ using DotMake.CommandLine; -namespace TALXIS.CLI.Workspace; +namespace TALXIS.CLI.Features.Workspace; [CliCommand( Description = "Reserved — not yet implemented.", diff --git a/src/TALXIS.CLI.Logging/JsonStderrLogger.cs b/src/TALXIS.CLI.Logging/JsonStderrLogger.cs index a559f31..da5661a 100644 --- a/src/TALXIS.CLI.Logging/JsonStderrLogger.cs +++ b/src/TALXIS.CLI.Logging/JsonStderrLogger.cs @@ -38,6 +38,12 @@ public void Log( message = $"{message} {exception}"; } + // Apply redaction at the sink so any code path that logs an exception + // containing a connection string or bearer token is sanitised before + // leaving the process. Individual call sites in MCP still call Redact + // explicitly, but this guard catches accidental leaks. + message = LogRedactionFilter.Redact(message) ?? string.Empty; + Dictionary? data = null; int? progress = null; @@ -57,7 +63,7 @@ public void Log( } data ??= new Dictionary(); - data[kvp.Key] = kvp.Value; + data[kvp.Key] = kvp.Value is string s ? LogRedactionFilter.Redact(s) : kvp.Value; } } diff --git a/src/TALXIS.CLI.Logging/LogRedactionFilter.cs b/src/TALXIS.CLI.Logging/LogRedactionFilter.cs index 8221ac5..b91b770 100644 --- a/src/TALXIS.CLI.Logging/LogRedactionFilter.cs +++ b/src/TALXIS.CLI.Logging/LogRedactionFilter.cs @@ -4,9 +4,14 @@ namespace TALXIS.CLI.Logging; /// /// Redacts sensitive information from log messages before forwarding. +/// Applied by McpLogForwarder and the JsonStderrLogger so any +/// path that funnels exception text / structured log values into stderr is +/// sanitised. /// public static partial class LogRedactionFilter { + private const string RedactedMarker = "***REDACTED***"; + private static readonly string HomePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); public static string? Redact(string? message) @@ -14,11 +19,24 @@ public static partial class LogRedactionFilter if (string.IsNullOrEmpty(message)) return message; - // Redact connection string values (AuthType=...;Password=...;) - message = ConnectionStringPasswordRegex().Replace(message, "$1***REDACTED***$3"); + // Bearer — common in HTTP traces. + message = BearerTokenRegex().Replace(message, $"Bearer {RedactedMarker}"); + + // Authorization: — Entire header value regardless of scheme. + message = AuthorizationHeaderRegex().Replace(message, $"Authorization: {RedactedMarker}"); + + // Bare JWTs (three dot-separated base64url segments). Placed after + // Bearer/Authorization replacements so we also catch JWTs that leak + // outside of headers (e.g. verbose SDK logs that print the raw token). + message = JwtRegex().Replace(message, RedactedMarker); + + // Redact connection string values. Extended beyond the original + // Password/ClientSecret/Secret/Token to also cover names the + // ServiceClient / MSAL / Azure SDKs emit in diagnostics. + message = ConnectionStringSecretRegex().Replace(message, "$1" + RedactedMarker + "$3"); // Redact tokens/keys in query parameters (?token=xxx, &key=xxx) - message = QueryParamSecretRegex().Replace(message, "$1***REDACTED***"); + message = QueryParamSecretRegex().Replace(message, "$1" + RedactedMarker); // Replace home directory paths with ~ if (!string.IsNullOrEmpty(HomePath)) @@ -29,9 +47,20 @@ public static partial class LogRedactionFilter return message; } - [GeneratedRegex(@"((?:Password|ClientSecret|Secret|Token)\s*=\s*)([^;&?]*)(;|$)", RegexOptions.IgnoreCase)] - private static partial Regex ConnectionStringPasswordRegex(); + [GeneratedRegex( + @"((?:Password|ClientSecret|ApplicationSecret|ApplicationPassword|Secret|Token|AccessToken|RefreshToken|IdToken|SasToken|ApiKey|Api_Key)\s*=\s*)([^;&?\s]*)(;|$)", + RegexOptions.IgnoreCase)] + private static partial Regex ConnectionStringSecretRegex(); - [GeneratedRegex(@"((?:token|key|secret|password|apikey|api_key)=)[^&\s]*", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"((?:token|key|secret|password|apikey|api_key|access_token|refresh_token|id_token)=)[^&\s]*", RegexOptions.IgnoreCase)] private static partial Regex QueryParamSecretRegex(); + + [GeneratedRegex(@"Bearer\s+[A-Za-z0-9\-._~+/]+=*", RegexOptions.IgnoreCase)] + private static partial Regex BearerTokenRegex(); + + [GeneratedRegex(@"Authorization\s*:\s*[^\r\n]+", RegexOptions.IgnoreCase)] + private static partial Regex AuthorizationHeaderRegex(); + + [GeneratedRegex(@"eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}")] + private static partial Regex JwtRegex(); } diff --git a/src/TALXIS.CLI.Logging/TALXIS.CLI.Logging.csproj b/src/TALXIS.CLI.Logging/TALXIS.CLI.Logging.csproj index c7b031c..099d63c 100644 --- a/src/TALXIS.CLI.Logging/TALXIS.CLI.Logging.csproj +++ b/src/TALXIS.CLI.Logging/TALXIS.CLI.Logging.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/TALXIS.CLI.MCP/CliCommandLookupService.cs b/src/TALXIS.CLI.MCP/CliCommandLookupService.cs index a169c9d..21a18a7 100644 --- a/src/TALXIS.CLI.MCP/CliCommandLookupService.cs +++ b/src/TALXIS.CLI.MCP/CliCommandLookupService.cs @@ -1,4 +1,5 @@ using System.Reflection; +using TALXIS.CLI.Core; namespace TALXIS.CLI.MCP { @@ -15,6 +16,9 @@ private void EnumerateRecursive(Type cmdType, List parentSegments, Type { var attr = Attribute.GetCustomAttribute(cmdType, typeof(DotMake.CommandLine.CliCommandAttribute)) as DotMake.CommandLine.CliCommandAttribute; if (attr == null) return; + + // Skip commands (and their entire sub-tree) marked as not relevant for MCP. + if (Attribute.IsDefined(cmdType, typeof(McpIgnoreAttribute))) return; var cliCommandNameResolver = new CliCommandNameResolver(); string name = cliCommandNameResolver.ResolveCommandName(cmdType, attr); bool isRoot = cmdType == rootType; diff --git a/src/TALXIS.CLI.MCP/CliSubprocessRunner.cs b/src/TALXIS.CLI.MCP/CliSubprocessRunner.cs index 103f3e9..9225755 100644 --- a/src/TALXIS.CLI.MCP/CliSubprocessRunner.cs +++ b/src/TALXIS.CLI.MCP/CliSubprocessRunner.cs @@ -109,6 +109,14 @@ private static Process StartProcess(IReadOnlyList cliArgs, string? worki // Enable structured JSON logging for MCP consumption startInfo.Environment["TXC_LOG_FORMAT"] = "json"; + // Force headless mode for every MCP-spawned tool invocation so that + // interactive auth flows (browser, device code, masked secret prompts) + // can never run: stdout is reserved for JSON-RPC frames and the + // process has stdin/stdout redirected. Leaf commands must fail fast + // with a structured AUTH_REQUIRED-style error instead of hanging on + // Console.ReadKey. See src/TALXIS.CLI.MCP/README.md#auth-contract. + startInfo.Environment["TXC_NON_INTERACTIVE"] = "1"; + startInfo.FileName = fileName; if (!string.IsNullOrWhiteSpace(assemblyPath)) { diff --git a/src/TALXIS.CLI.MCP/CopilotInstructionsCliCommand.cs b/src/TALXIS.CLI.MCP/CopilotInstructionsCliCommand.cs index 2bea8c2..2ae70e2 100644 --- a/src/TALXIS.CLI.MCP/CopilotInstructionsCliCommand.cs +++ b/src/TALXIS.CLI.MCP/CopilotInstructionsCliCommand.cs @@ -1,6 +1,6 @@ using DotMake.CommandLine; using System.ComponentModel; -using TALXIS.CLI.Shared; +using TALXIS.CLI.Core; namespace TALXIS.CLI.MCP { diff --git a/src/TALXIS.CLI.MCP/McpToolRegistry.cs b/src/TALXIS.CLI.MCP/McpToolRegistry.cs index 7943a9a..0d1bfa5 100644 --- a/src/TALXIS.CLI.MCP/McpToolRegistry.cs +++ b/src/TALXIS.CLI.MCP/McpToolRegistry.cs @@ -17,11 +17,11 @@ public class McpToolRegistry /// private static readonly HashSet _longRunningCommandTypes = new() { - typeof(TALXIS.CLI.Data.DataPackageImportCliCommand), - typeof(TALXIS.CLI.Environment.Package.PackageImportCliCommand), - typeof(TALXIS.CLI.Environment.Package.PackageUninstallCliCommand), - typeof(TALXIS.CLI.Environment.Solution.SolutionImportCliCommand), - typeof(TALXIS.CLI.Environment.Solution.SolutionUninstallCliCommand), + typeof(TALXIS.CLI.Features.Data.DataPackageImportCliCommand), + typeof(TALXIS.CLI.Features.Environment.Package.PackageImportCliCommand), + typeof(TALXIS.CLI.Features.Environment.Package.PackageUninstallCliCommand), + typeof(TALXIS.CLI.Features.Environment.Solution.SolutionImportCliCommand), + typeof(TALXIS.CLI.Features.Environment.Solution.SolutionUninstallCliCommand), }; /// diff --git a/src/TALXIS.CLI.MCP/Program.cs b/src/TALXIS.CLI.MCP/Program.cs index 049309a..3c71667 100644 --- a/src/TALXIS.CLI.MCP/Program.cs +++ b/src/TALXIS.CLI.MCP/Program.cs @@ -328,7 +328,7 @@ async Task ExecuteMcpSpecificToolWithCapturedOutputAsync(Type co // Redirect OutputWriter (result data) to our capture buffer. // In-process MCP tools use OutputWriter.WriteLine() for result data. - using var redirect = TALXIS.CLI.Shared.OutputWriter.RedirectTo(output); + using var redirect = TALXIS.CLI.Core.OutputWriter.RedirectTo(output); try { diff --git a/src/TALXIS.CLI.MCP/README.md b/src/TALXIS.CLI.MCP/README.md index 5b7f523..74ed796 100644 --- a/src/TALXIS.CLI.MCP/README.md +++ b/src/TALXIS.CLI.MCP/README.md @@ -140,4 +140,91 @@ To call a tool (replace arguments as needed): --- -For more details, see the main [TALXIS CLI README](../../README.md). \ No newline at end of file +## Auth contract (stdio) + +**The MCP server never runs an interactive auth flow.** Every `txc` tool +subprocess spawned by this server is forced headless — stdout is reserved +for JSON-RPC frames and the child has stdin/stdout redirected, so a +browser/device-code/masked-prompt attempt would either hang the session +or corrupt the transport. `CliSubprocessRunner` sets +`TXC_NON_INTERACTIVE=1` unconditionally on every spawn. + +Prerequisites, before invoking any tool that touches a Dataverse +environment (or any other Connection-bound tool): + +1. On the human's machine, run `txc config auth login` (interactive + browser) or `txc config auth add-service-principal` once to register + a credential and prime the MSAL token cache. +2. Run `txc config connection create --provider dataverse ...` + to register the endpoint. +3. Run `txc config profile create --auth --connection ` + to bind them, then `txc config profile select ` (or pin the + workspace via `txc config profile pin`). + +After that, MCP tool calls resolve the active profile silently via the +acquired token cache or stored SPN secret. If resolution fails (expired +refresh token, missing credential, broken config), the subprocess exits +non-zero and the MCP server surfaces the error through the tool-call +result; the structured log line includes the fail-fast remedy string +(`txc config profile validate `). + +### Per-call profile override + +Any Connection-touching MCP tool accepts an optional `profile` argument +which is forwarded to the child as `--profile `. This lets a +single MCP session switch between profiles per call without restarting +the server — e.g. an agent can target `customer-a-dev` for one tool and +`customer-b-prod` for the next. + +### Client env allow-list (`mcpServers..env`) + +The MCP server inherits the client's environment and the child CLI +subprocess inherits that. The following are meaningful to `txc`; anything +else is ignored: + +| Variable | Purpose | +|---|---| +| `TXC_PROFILE` | Select active profile for every call in this MCP session (overridden by per-call `profile` argument when supplied). | +| `TXC_CONFIG_DIR` | Override entire config directory (total CI isolation). | +| `TXC_NON_INTERACTIVE` | Redundant here — MCP already forces this to `1`. | +| `TXC_LOG_FORMAT` | Redundant here — MCP already forces `json`. | +| `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID` | Service-principal fallback for CI / headless runners. | +| `AZURE_FEDERATED_TOKEN_FILE` | Kubernetes / Azure workload-identity federation. | +| `ACTIONS_ID_TOKEN_REQUEST_URL`, `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | GitHub Actions OIDC federation. | +| `TXC_ADO_ID_TOKEN_REQUEST_URL`, `TXC_ADO_ID_TOKEN_REQUEST_TOKEN` | Azure DevOps pipelines workload-identity federation (legacy `PAC_ADO_*` also honored). | + +Secrets (client secrets, PATs, certificate passwords) are **never** +accepted as plain MCP tool arguments — they are stored in the OS-level +secret vault via `txc config auth add-service-principal` and referenced +from config by `SecretRef` handle only. + +### Log redaction + +`McpLogForwarder` runs every child stderr JSON log line through +`LogRedactionFilter` before emitting `notifications/message` to the MCP +client. Patterns covered: `Bearer `, `Authorization:` headers, +bare JWTs, connection-string secret keys (`Password`, `ClientSecret`, +`AccessToken`, `RefreshToken`, etc.) and URL query-param secrets +(`code=`, `token=`, `access_token=`). This is the last-chance belt for +accidental `logger.LogError(ex, ...)` leaks where the Dataverse or +Microsoft.Identity SDKs embed tokens inside exception messages. + +### Forbidden patterns (forward-compat for HTTP transport) + +When the HTTP/SSE transport ships, `txc-mcp` will be an **OAuth resource +server only** — Entra ID remains the authorization server. The following +patterns are forbidden by the MCP spec and will not be implemented: + +- **Token passthrough**: never forward a client's bearer token directly + to Dataverse — always use on-behalf-of or a separate service account. +- **Tokens in URIs**: never embed access tokens in query strings. +- **Non-HTTPS redirects** (except loopback for local dev). +- **Missing PKCE**: all OAuth flows must use PKCE. +- **Missing audience check**: tokens must be audience-bound to the + `txc-mcp` resource URI (RFC 8707). + +See `docs/mcp-http-auth-notes.md` (design notes, no code in v1). + +--- + +For more details, see the main [TALXIS CLI README](../../README.md). diff --git a/src/TALXIS.CLI.MCP/TALXIS.CLI.MCP.csproj b/src/TALXIS.CLI.MCP/TALXIS.CLI.MCP.csproj index d7820d0..785dfd4 100644 --- a/src/TALXIS.CLI.MCP/TALXIS.CLI.MCP.csproj +++ b/src/TALXIS.CLI.MCP/TALXIS.CLI.MCP.csproj @@ -36,7 +36,7 @@ - + diff --git a/src/TALXIS.CLI.Platform.Dataverse/Authority/AuthorityChallengeResolver.cs b/src/TALXIS.CLI.Platform.Dataverse/Authority/AuthorityChallengeResolver.cs new file mode 100644 index 0000000..ed22ae8 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Authority/AuthorityChallengeResolver.cs @@ -0,0 +1,95 @@ +using System.Net; +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace TALXIS.CLI.Platform.Dataverse.Authority; + +/// +/// Resolves the Entra authority URI for a Dataverse environment by issuing +/// an unauthenticated request and parsing the WWW-Authenticate +/// header's authorization_uri parameter. +/// +/// +/// Mirrors IAuthorityResolver.GetAuthority(environmentUrl) in pac +/// (see temp/pac-auth-research.md). Used when the tenant is unknown +/// or when validating sovereign-cloud deployments that don't map cleanly +/// to a host suffix. Prefer when the cloud +/// is already known on the Connection. +/// +public sealed class AuthorityChallengeResolver +{ + private static readonly Uri WhoAmIPath = new("/api/data/v9.2/WhoAmI", UriKind.Relative); + + private readonly HttpClient _http; + private readonly ILogger _logger; + private readonly bool _disposeHttp; + + public AuthorityChallengeResolver(HttpClient? http = null, ILogger? logger = null) + { + _http = http ?? new HttpClient(); + _disposeHttp = http is null; + _logger = logger ?? NullLogger.Instance; + } + + /// + /// Issues an unauthenticated GET to /api/data/v9.2/WhoAmI on the + /// environment URL and returns the authorization_uri reported by + /// Dataverse's WWW-Authenticate: Bearer challenge. + /// Throws if the server does not + /// return a 401 with a parsable Bearer challenge. + /// + public async Task GetAuthorityAsync(Uri environmentUrl, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(environmentUrl); + var probe = new Uri(environmentUrl, WhoAmIPath); + using var response = await _http.GetAsync(probe, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + if (response.StatusCode != HttpStatusCode.Unauthorized) + { + throw new InvalidOperationException( + $"Expected HTTP 401 from {probe} but got {(int)response.StatusCode} {response.StatusCode}."); + } + + foreach (var header in response.Headers.WwwAuthenticate) + { + if (TryParseAuthorizationUri(header, out var authority)) + { + _logger.LogDebug("Resolved authority {Authority} from WWW-Authenticate challenge at {Probe}.", authority, probe); + return authority; + } + } + + throw new InvalidOperationException( + $"Dataverse challenge at {probe} did not include an authorization_uri parameter."); + } + + internal static bool TryParseAuthorizationUri(AuthenticationHeaderValue header, out Uri authority) + { + authority = null!; + if (!string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(header.Parameter)) + return false; + + foreach (var segment in header.Parameter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var eq = segment.IndexOf('='); + if (eq <= 0) continue; + var key = segment[..eq].Trim(); + var value = segment[(eq + 1)..].Trim().Trim('"'); + if (!string.Equals(key, "authorization_uri", StringComparison.OrdinalIgnoreCase)) + continue; + if (Uri.TryCreate(value, UriKind.Absolute, out var parsed)) + { + authority = parsed; + return true; + } + } + return false; + } + + internal void DisposeOwnedHttpClient() + { + if (_disposeHttp) _http.Dispose(); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Authority/DataverseCloudMap.cs b/src/TALXIS.CLI.Platform.Dataverse/Authority/DataverseCloudMap.cs new file mode 100644 index 0000000..3682829 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Authority/DataverseCloudMap.cs @@ -0,0 +1,70 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Platform.Dataverse.Authority; + +/// +/// Maps to its Microsoft Entra authority host +/// and infers the cloud from a Dataverse environment URL host suffix. +/// +/// +/// Host/authority constants mirror bolt.authentication.AuthorityInfo +/// in pac 2.6.3 (see temp/pac-auth-research.md). The Public authority is +/// also used for Preprod / Test tenants; we don't split those for v1. +/// +public static class DataverseCloudMap +{ + public const string PublicAuthority = "https://login.microsoftonline.com"; + public const string UsGovAuthority = "https://login.microsoftonline.us"; + public const string ChinaAuthority = "https://login.partner.microsoftonline.cn"; + + /// Entra authority host for the given cloud, without trailing slash or tenant segment. + public static string GetAuthorityHost(CloudInstance cloud) => cloud switch + { + CloudInstance.Public => PublicAuthority, + CloudInstance.Gcc => PublicAuthority, + CloudInstance.GccHigh => UsGovAuthority, + CloudInstance.Dod => UsGovAuthority, + CloudInstance.China => ChinaAuthority, + _ => throw new ArgumentOutOfRangeException(nameof(cloud), cloud, "Unknown cloud instance."), + }; + + /// + /// Returns the full MSAL authority URI for the cloud. + /// If is provided it's appended as the + /// directory segment; otherwise organizations is used so MSAL can + /// resolve the tenant at login time. + /// + public static Uri BuildAuthorityUri(CloudInstance cloud, string? tenantId) + { + var host = GetAuthorityHost(cloud); + var directory = string.IsNullOrWhiteSpace(tenantId) ? "organizations" : tenantId.Trim(); + return new Uri($"{host}/{directory}"); + } + + /// + /// Infers a from a Dataverse environment URL + /// by matching well-known host suffixes. Returns null when the host + /// does not match any known sovereign pattern (caller should fall back to + /// or the value stored on the Connection). + /// + public static CloudInstance? TryInferFromEnvironmentUrl(Uri environmentUrl) + { + ArgumentNullException.ThrowIfNull(environmentUrl); + var host = environmentUrl.Host.ToLowerInvariant(); + + // DoD → *.crm.appsplatform.us ; GccHigh → *.crm.microsoftdynamics.us ; Gcc → *.crm9.dynamics.com ; China → *.crm.dynamics.cn + if (host.EndsWith(".crm.appsplatform.us", StringComparison.Ordinal)) + return CloudInstance.Dod; + if (host.EndsWith(".crm.microsoftdynamics.us", StringComparison.Ordinal) || + host.EndsWith(".crm.dynamics.us", StringComparison.Ordinal)) + return CloudInstance.GccHigh; + if (host.EndsWith(".crm9.dynamics.com", StringComparison.Ordinal)) + return CloudInstance.Gcc; + if (host.EndsWith(".crm.dynamics.cn", StringComparison.Ordinal)) + return CloudInstance.China; + if (host.EndsWith(".dynamics.com", StringComparison.Ordinal)) + return CloudInstance.Public; + + return null; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Bootstrapping/DataverseConnectionProviderBootstrapper.cs b/src/TALXIS.CLI.Platform.Dataverse/Bootstrapping/DataverseConnectionProviderBootstrapper.cs new file mode 100644 index 0000000..47632d4 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Bootstrapping/DataverseConnectionProviderBootstrapper.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Bootstrapping; +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Platform.Dataverse.Bootstrapping; + +/// +/// Dataverse implementation of . +/// Drives: headless guard → interactive browser login → credential alias +/// resolve + upsert → connection upsert. Called by profile create --url. +/// +public sealed class DataverseConnectionProviderBootstrapper : IConnectionProviderBootstrapper +{ + private readonly IInteractiveLoginService _login; + private readonly ICredentialStore _credentials; + private readonly ConnectionUpsertService _connections; + private readonly IHeadlessDetector _headless; + private readonly ILogger _logger; + + public DataverseConnectionProviderBootstrapper( + IInteractiveLoginService login, + ICredentialStore credentials, + ConnectionUpsertService connections, + IHeadlessDetector headless, + ILogger logger) + { + _login = login ?? throw new ArgumentNullException(nameof(login)); + _credentials = credentials ?? throw new ArgumentNullException(nameof(credentials)); + _connections = connections ?? throw new ArgumentNullException(nameof(connections)); + _headless = headless ?? throw new ArgumentNullException(nameof(headless)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public ProviderKind Provider => ProviderKind.Dataverse; + + public async Task BootstrapAsync( + ProfileBootstrapRequest request, CancellationToken ct) + { + if (request is null) throw new ArgumentNullException(nameof(request)); + + _logger.LogInformation("Starting interactive sign-in for '{Url}'...", request.EnvironmentUrl); + var acquired = await InteractiveCredentialBootstrapper.AcquireAndPersistAsync( + _login, _credentials, _headless, + request.TenantId, request.Cloud, explicitAlias: null, ct).ConfigureAwait(false); + + var upsert = await _connections.ValidateAndUpsertAsync( + request.Name, + request.Provider, + request.EnvironmentUrl, + request.Cloud, + organizationId: null, + tenantId: request.TenantId ?? acquired.TenantId, + description: request.Description, + ct).ConfigureAwait(false); + + if (upsert.Error is not null) + return new ProfileBootstrapResult(acquired.Credential, null, acquired.Upn, upsert.Error); + + return new ProfileBootstrapResult(acquired.Credential, upsert.Connection, acquired.Upn, null); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/DataverseConnectionProvider.cs b/src/TALXIS.CLI.Platform.Dataverse/DataverseConnectionProvider.cs new file mode 100644 index 0000000..e021ff5 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/DataverseConnectionProvider.cs @@ -0,0 +1,91 @@ +using System.Collections.Frozen; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.Dataverse.Msal; + +namespace TALXIS.CLI.Platform.Dataverse; + +/// +/// for . +/// Validates that the connection metadata is well-formed and that the +/// credential kind is supported by this provider. Token acquisition and +/// WhoAmI checks land in the refactor milestone once auth commands are +/// wired — this v1 provider reports structural validation only so the +/// profile/connection command plumbing can round-trip safely. +/// +public sealed class DataverseConnectionProvider : IConnectionProvider +{ + private static readonly FrozenSet Supported = new[] + { + CredentialKind.InteractiveBrowser, + CredentialKind.DeviceCode, + CredentialKind.ClientSecret, + CredentialKind.ClientCertificate, + CredentialKind.WorkloadIdentityFederation, + CredentialKind.ManagedIdentity, + CredentialKind.AzureCli, + }.ToFrozenSet(); + + private readonly DataverseMsalClientFactory _clientFactory; + private readonly IDataverseLiveChecker _liveChecker; + private readonly ILogger _logger; + + public DataverseConnectionProvider( + DataverseMsalClientFactory clientFactory, + IDataverseLiveChecker liveChecker, + ILogger? logger = null) + { + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + _liveChecker = liveChecker ?? throw new ArgumentNullException(nameof(liveChecker)); + _logger = logger ?? NullLogger.Instance; + } + + public ProviderKind ProviderKind => ProviderKind.Dataverse; + + public IReadOnlySet SupportedCredentialKinds => Supported; + + public async Task ValidateAsync(Connection connection, Credential credential, ValidationMode mode, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + + if (connection.Provider != ProviderKind.Dataverse) + throw new InvalidOperationException( + $"Connection '{connection.Id}' has provider {connection.Provider}, expected {ProviderKind.Dataverse}."); + + if (string.IsNullOrWhiteSpace(connection.EnvironmentUrl)) + throw new InvalidOperationException( + $"Dataverse connection '{connection.Id}' is missing EnvironmentUrl."); + + if (!Uri.TryCreate(connection.EnvironmentUrl, UriKind.Absolute, out var envUri) || + (envUri.Scheme != Uri.UriSchemeHttp && envUri.Scheme != Uri.UriSchemeHttps)) + { + throw new InvalidOperationException( + $"Dataverse connection '{connection.Id}' EnvironmentUrl '{connection.EnvironmentUrl}' is not an absolute http(s) URI."); + } + + if (!Supported.Contains(credential.Kind)) + { + throw new InvalidOperationException( + $"Credential kind {credential.Kind} is not supported by the Dataverse provider."); + } + + // Probe MSAL builder wiring so authority resolution errors surface during `config profile validate` + // rather than at first token acquisition. + _ = DataverseMsalClientFactory.ResolveAuthority(connection, credential); + + _logger.LogDebug( + "Dataverse connection '{ConnectionId}' validated structurally (envUrl={EnvUrl}, cloud={Cloud}, kind={Kind}).", + connection.Id, envUri, connection.Cloud, credential.Kind); + + if (mode == ValidationMode.Live) + { + var result = await _liveChecker.CheckAsync(connection, credential, ct).ConfigureAwait(false); + _logger.LogInformation( + "Dataverse WhoAmI succeeded (userId={UserId}, orgId={OrgId}).", + result.UserId, result.OrganizationId); + } + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/DataverseLiveChecker.cs b/src/TALXIS.CLI.Platform.Dataverse/DataverseLiveChecker.cs new file mode 100644 index 0000000..86632cf --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/DataverseLiveChecker.cs @@ -0,0 +1,115 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.Dataverse.Runtime; + +namespace TALXIS.CLI.Platform.Dataverse; + +/// +/// Real : acquires an access token via +/// and calls the Dataverse +/// WhoAmI Web API endpoint to confirm the identity and the endpoint +/// are both live. Replaces the previous stub implementation. +/// +/// +/// +/// The check uses the Web API (/api/data/v9.2/WhoAmI) rather than the +/// SDK ServiceClient. This keeps live-validate cheap (a single HTTP +/// round trip, no SOAP metadata fetch) and independent of the Dataverse SDK +/// caching. The response shape is stable across v9.x. +/// +/// +/// HTTP failures surface as with a +/// remediation hint that includes the status code and response body so the +/// profile validate command can show the user enough to act on. +/// +/// +public sealed class DataverseLiveChecker : IDataverseLiveChecker +{ + private const string WhoAmIRelativePath = "/api/data/v9.2/WhoAmI"; + + private readonly IDataverseAccessTokenService _tokens; + private readonly IHttpClientFactoryWrapper _httpFactory; + private readonly ILogger _logger; + + public DataverseLiveChecker( + IDataverseAccessTokenService tokens, + IHttpClientFactoryWrapper? httpFactory = null, + ILogger? logger = null) + { + _tokens = tokens ?? throw new ArgumentNullException(nameof(tokens)); + _httpFactory = httpFactory ?? DefaultHttpClientFactoryWrapper.Instance; + _logger = logger ?? NullLogger.Instance; + } + + public async Task CheckAsync(TALXIS.CLI.Core.Model.Connection connection, Credential credential, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + + if (string.IsNullOrWhiteSpace(connection.EnvironmentUrl)) + throw new InvalidOperationException($"Dataverse connection '{connection.Id}' is missing EnvironmentUrl."); + if (!Uri.TryCreate(connection.EnvironmentUrl, UriKind.Absolute, out var envUri)) + throw new InvalidOperationException($"Dataverse connection '{connection.Id}' EnvironmentUrl '{connection.EnvironmentUrl}' is not a valid absolute URI."); + + var token = await _tokens.AcquireAsync(connection, credential, ct).ConfigureAwait(false); + + var whoAmI = new Uri(envUri, WhoAmIRelativePath); + using var http = _httpFactory.Create(); + using var req = new HttpRequestMessage(HttpMethod.Get, whoAmI); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + req.Headers.Add("OData-MaxVersion", "4.0"); + req.Headers.Add("OData-Version", "4.0"); + + using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + if (!resp.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Dataverse WhoAmI failed ({(int)resp.StatusCode} {resp.ReasonPhrase}) for '{envUri}': {Truncate(body, 500)}"); + } + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + var userId = ReadGuid(root, "UserId"); + var businessUnitId = ReadGuid(root, "BusinessUnitId"); + var organizationId = ReadGuid(root, "OrganizationId"); + + _logger.LogDebug( + "WhoAmI OK for '{EnvUrl}' (userId={UserId}, orgId={OrgId}).", + envUri, userId, organizationId); + + return new DataverseLiveCheckResult(userId, businessUnitId, organizationId); + } + + private static Guid ReadGuid(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var element) || element.ValueKind != JsonValueKind.String) + throw new InvalidOperationException($"WhoAmI response missing '{property}' field."); + if (!Guid.TryParse(element.GetString(), out var guid)) + throw new InvalidOperationException($"WhoAmI '{property}' is not a valid GUID: '{element.GetString()}'."); + return guid; + } + + private static string Truncate(string s, int max) + => string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s[..max] + "..."); +} + +/// +/// Thin seam so tests can swap the used by +/// without standing up a real HTTP stack. +/// +public interface IHttpClientFactoryWrapper +{ + HttpClient Create(); +} + +internal sealed class DefaultHttpClientFactoryWrapper : IHttpClientFactoryWrapper +{ + public static readonly DefaultHttpClientFactoryWrapper Instance = new(); + public HttpClient Create() => new(); +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs new file mode 100644 index 0000000..5b8ea9c --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Authority; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Platform.Dataverse.Msal; +using TALXIS.CLI.Platform.Dataverse.Services; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Platform.Dataverse.DependencyInjection; + +public static class DataverseProviderServiceCollectionExtensions +{ + /// + /// Registers the Dataverse , the shared + /// MSAL client factory, the authority-challenge resolver, and the MSAL + /// token-cache binder. Must be called after + /// AddTxcConfigCore(). + /// + public static IServiceCollection AddTxcDataverseProvider(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + + // Singleton so MsalCacheHelper is instantiated once per process. Same + // rationale as MsalBackedCredentialVault in ConfigServiceCollectionExtensions. + services.AddSingleton(sp => + { + var paths = sp.GetRequiredService(); + var env = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + return DataverseTokenCacheBinder + .CreateAsync(paths, env, logger) + .GetAwaiter().GetResult(); + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/DependencyInjection/TxcServicesBootstrap.cs b/src/TALXIS.CLI.Platform.Dataverse/DependencyInjection/TxcServicesBootstrap.cs new file mode 100644 index 0000000..7b51511 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/DependencyInjection/TxcServicesBootstrap.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using TALXIS.CLI.Core.DependencyInjection; + +namespace TALXIS.CLI.Platform.Dataverse.DependencyInjection; + +/// +/// Composition root for a Config core + Dataverse provider container. +/// Idempotent. Called by: +/// +/// The TALXIS.CLI host on startup. +/// The Package Deployer / CMT subprocess entry points that run +/// as their own mini-hosts. +/// +/// Lives alongside +/// because the Dataverse platform adapter owns its composition; feature +/// projects never touch this. +/// +public static class TxcServicesBootstrap +{ + public static void EnsureInitialized() + { + if (TxcServices.IsInitialized) return; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTxcConfigCore(); + services.AddTxcDataverseProvider(); + + var provider = services.BuildServiceProvider(); + TxcServices.Initialize(provider); + } +} diff --git a/src/TALXIS.CLI.Environment/AssemblyInfo.cs b/src/TALXIS.CLI.Platform.Dataverse/Domain/AssemblyInfo.cs similarity index 100% rename from src/TALXIS.CLI.Environment/AssemblyInfo.cs rename to src/TALXIS.CLI.Platform.Dataverse/Domain/AssemblyInfo.cs diff --git a/src/TALXIS.CLI.Platform.Dataverse/Domain/DataverseConnection.cs b/src/TALXIS.CLI.Platform.Dataverse/Domain/DataverseConnection.cs new file mode 100644 index 0000000..18eb383 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Domain/DataverseConnection.cs @@ -0,0 +1,34 @@ +using Microsoft.PowerPlatform.Dataverse.Client; + +namespace TALXIS.CLI.Platform.Dataverse; + +/// +/// Disposable wrapper around a . Disposing +/// releases the underlying client. +/// +public sealed class DataverseConnection : IDisposable +{ + public ServiceClient Client { get; } + + private DataverseConnection(ServiceClient client) + { + Client = client; + } + + /// + /// Factory used by out-of-assembly callers (e.g. the profile-based + /// IDataverseConnectionFactory in + /// TALXIS.CLI.Platform.Dataverse) that already own a + /// ready . + /// + public static DataverseConnection FromServiceClient(ServiceClient client) + { + ArgumentNullException.ThrowIfNull(client); + return new DataverseConnection(client); + } + + public void Dispose() + { + Client.Dispose(); + } +} diff --git a/src/TALXIS.CLI.Dataverse/DataverseDateTime.cs b/src/TALXIS.CLI.Platform.Dataverse/Domain/DataverseDateTime.cs similarity index 95% rename from src/TALXIS.CLI.Dataverse/DataverseDateTime.cs rename to src/TALXIS.CLI.Platform.Dataverse/Domain/DataverseDateTime.cs index 50dabc0..4ca4524 100644 --- a/src/TALXIS.CLI.Dataverse/DataverseDateTime.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Domain/DataverseDateTime.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Dataverse; +namespace TALXIS.CLI.Platform.Dataverse; /// /// UTC normalisation helpers. Dataverse returns UTC but the SDK often surfaces diff --git a/src/TALXIS.CLI.Dataverse/DataverseSchema.cs b/src/TALXIS.CLI.Platform.Dataverse/Domain/DataverseSchema.cs similarity index 93% rename from src/TALXIS.CLI.Dataverse/DataverseSchema.cs rename to src/TALXIS.CLI.Platform.Dataverse/Domain/DataverseSchema.cs index 4971fa1..37d0260 100644 --- a/src/TALXIS.CLI.Dataverse/DataverseSchema.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Domain/DataverseSchema.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Dataverse; +namespace TALXIS.CLI.Platform.Dataverse; /// /// Logical names of platform-level Dataverse entities that are not tied to a specific domain. diff --git a/src/TALXIS.CLI.Platform.Dataverse/IDataverseLiveChecker.cs b/src/TALXIS.CLI.Platform.Dataverse/IDataverseLiveChecker.cs new file mode 100644 index 0000000..0c48d58 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/IDataverseLiveChecker.cs @@ -0,0 +1,23 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Platform.Dataverse; + +/// +/// Provider-specific live-check seam consumed by +/// when +/// is +/// requested. Default implementation wires through +/// DataverseMsalClientFactory + HTTP to issue a WhoAmI request; +/// tests inject a fake so they can assert command behaviour without +/// standing up MSAL / HTTP. Full default HTTP implementation lands with +/// the refactor-dataverse-commands milestone — until then the +/// default impl surfaces a clear "not implemented" error that the +/// profile validate command maps to exit 1. +/// +public interface IDataverseLiveChecker +{ + Task CheckAsync(Connection connection, Credential credential, CancellationToken ct); +} + +/// Canonical payload returned from a successful WhoAmI call. +public sealed record DataverseLiveCheckResult(Guid UserId, Guid BusinessUnitId, Guid OrganizationId); diff --git a/src/TALXIS.CLI.Platform.Dataverse/Msal/DataverseInteractiveLoginService.cs b/src/TALXIS.CLI.Platform.Dataverse/Msal/DataverseInteractiveLoginService.cs new file mode 100644 index 0000000..b18ef86 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Msal/DataverseInteractiveLoginService.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Client; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Platform.Dataverse.Msal; + +/// +/// MSAL-backed . Builds a fresh +/// public-client application per call — MSAL's cache is shared via +/// , so the new client instance +/// benefits from earlier sign-ins. +/// +/// +/// Scopes: openid profile offline_access only. This is an +/// identity-warming sign-in: we want the refresh token persisted in the +/// MSAL cache so later profile-bound commands can acquire Dataverse +/// access tokens silently. Running the Dataverse //.default scope +/// at this point would force the user to name an env that may not exist +/// yet in their config. +/// +public sealed class DataverseInteractiveLoginService : IInteractiveLoginService +{ + private static readonly string[] SignInScopes = ["openid", "profile", "offline_access"]; + + private readonly DataverseMsalClientFactory _factory; + private readonly DataverseTokenCacheBinder _cacheBinder; + private readonly ILogger _logger; + + public DataverseInteractiveLoginService( + DataverseMsalClientFactory factory, + DataverseTokenCacheBinder cacheBinder, + ILogger? logger = null) + { + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _cacheBinder = cacheBinder ?? throw new ArgumentNullException(nameof(cacheBinder)); + _logger = logger ?? NullLogger.Instance; + } + + public async Task LoginAsync( + string? tenantId, + CloudInstance cloud, + CancellationToken ct) + { + var app = _factory.BuildPublicClientForLogin(tenantId, cloud); + _cacheBinder.Attach(app.UserTokenCache); + + _logger.LogInformation( + "Launching browser for interactive sign-in (tenant={Tenant}, cloud={Cloud}).", + tenantId ?? "organizations", cloud); + + AuthenticationResult result = await app + .AcquireTokenInteractive(SignInScopes) + .WithUseEmbeddedWebView(false) + .ExecuteAsync(ct) + .ConfigureAwait(false); + + var upn = result.Account?.Username; + if (string.IsNullOrWhiteSpace(upn)) + throw new InvalidOperationException( + "Sign-in succeeded but MSAL did not return a UPN. Try again with --alias to provide one explicitly."); + + return new InteractiveLoginResult(upn, result.TenantId); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Msal/DataverseMsalClientFactory.cs b/src/TALXIS.CLI.Platform.Dataverse/Msal/DataverseMsalClientFactory.cs new file mode 100644 index 0000000..21e45ee --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Msal/DataverseMsalClientFactory.cs @@ -0,0 +1,164 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Identity.Client; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.Dataverse.Authority; + +namespace TALXIS.CLI.Platform.Dataverse.Msal; + +/// +/// Callback that returns a client assertion (JWT) for federated credential +/// flows (GitHub OIDC, ADO Workload Identity). Registered per-credential +/// when the Credential's is +/// . +/// +public delegate Task ClientAssertionCallback(CancellationToken ct); + +/// +/// Inputs required to rehydrate a confidential-client secret or certificate +/// from at build time. +/// +public sealed record ConfidentialClientMaterial +{ + public string? ClientSecret { get; init; } + public X509Certificate2? Certificate { get; init; } + public ClientAssertionCallback? AssertionCallback { get; init; } + + public bool IsEmpty => + string.IsNullOrEmpty(ClientSecret) && Certificate is null && AssertionCallback is null; +} + +/// +/// Builds MSAL public- and confidential-client applications wired to the +/// correct Dataverse authority. Callers (DataverseConnectionProvider, +/// auth commands) never instantiate MSAL builders directly — this is the +/// single place that pins our client constants and authority policy. +/// +/// +/// Pinned choices (parity with pac 2.6.3, see temp/pac-auth-research.md): +/// +/// Public client id 9cee029c-6210-4654-90bb-17e6e9d36617, redirect http://localhost. +/// validateAuthority: false on every builder — required for sovereign clouds. +/// Certificate flow sends sendX5C: true to enable subject-name/issuer auth. +/// +/// Token cache registration is handled separately by the caller so that the +/// same MsalCacheHelper instance is reused across clients (critical for +/// keeping Keychain prompt count low on macOS — see keychain-prompt-research.md). +/// +public sealed class DataverseMsalClientFactory +{ + /// + /// Public-client application id used by pac CLI. Reused verbatim so that + /// first-run users don't need to register their own Entra app. Confidential + /// flows use the Credential's own ApplicationId instead. + /// + public const string PublicClientId = "9cee029c-6210-4654-90bb-17e6e9d36617"; + + /// Redirect URI registered on the app. + public const string PublicRedirectUri = "http://localhost"; + + /// + /// Builds a public-client application (interactive / device-code / silent) + /// for the given connection. Cloud precedence: explicit on + /// → inferred from EnvironmentUrl → + /// . + /// + public IPublicClientApplication BuildPublicClient(Connection connection) + { + ArgumentNullException.ThrowIfNull(connection); + var authority = ResolveAuthority(connection); + + return PublicClientApplicationBuilder + .Create(PublicClientId) + .WithRedirectUri(PublicRedirectUri) + .WithAuthority(authority.AbsoluteUri, validateAuthority: false) + .Build(); + } + + /// + /// Builds a public-client application for config auth login, when + /// we don't yet have a . + /// of null resolves to organizations — MSAL then infers the + /// tenant from whatever account the user picks in the browser. We + /// deliberately avoid /common because personal MS accounts can + /// never hold a Dataverse license. + /// + public IPublicClientApplication BuildPublicClientForLogin(string? tenantId, CloudInstance cloud = CloudInstance.Public) + { + var authority = DataverseCloudMap.BuildAuthorityUri(cloud, tenantId); + + return PublicClientApplicationBuilder + .Create(PublicClientId) + .WithRedirectUri(PublicRedirectUri) + .WithAuthority(authority.AbsoluteUri, validateAuthority: false) + .Build(); + } + + /// + /// Builds a confidential-client application. One of + /// , + /// , or + /// must be set — + /// the resolver/vault layer populates the right one based on the + /// Credential's . + /// + public IConfidentialClientApplication BuildConfidentialClient( + Connection connection, + Credential credential, + ConfidentialClientMaterial material) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + ArgumentNullException.ThrowIfNull(material); + + if (string.IsNullOrWhiteSpace(credential.ApplicationId)) + throw new InvalidOperationException( + $"Credential '{credential.Id}' of kind {credential.Kind} requires ApplicationId for confidential flows."); + if (material.IsEmpty) + throw new InvalidOperationException( + $"Credential '{credential.Id}' of kind {credential.Kind} has no client secret, certificate, or assertion callback."); + + var authority = ResolveAuthority(connection, credential); + var builder = ConfidentialClientApplicationBuilder + .Create(credential.ApplicationId) + .WithAuthority(authority.AbsoluteUri, validateAuthority: false); + + if (!string.IsNullOrEmpty(material.ClientSecret)) + builder = builder.WithClientSecret(material.ClientSecret); + else if (material.Certificate is not null) + builder = builder.WithCertificate(material.Certificate, sendX5C: true); + else if (material.AssertionCallback is not null) + builder = builder.WithClientAssertion(ct => material.AssertionCallback(ct)); + + return builder.Build(); + } + + /// + /// Internal authority resolution: prefer an already-challenged authority + /// stored by the caller (future), then explicit cloud on Connection / + /// Credential, then inference from EnvironmentUrl. + /// + internal static Uri ResolveAuthority(Connection connection, Credential? credential = null) + { + var cloud = + connection.Cloud + ?? credential?.Cloud + ?? (TryParseUri(connection.EnvironmentUrl, out var env) + ? DataverseCloudMap.TryInferFromEnvironmentUrl(env!) + : null) + ?? CloudInstance.Public; + + var tenant = connection.TenantId ?? credential?.TenantId; + return DataverseCloudMap.BuildAuthorityUri(cloud, tenant); + } + + private static bool TryParseUri(string? raw, out Uri? uri) + { + if (string.IsNullOrWhiteSpace(raw)) + { + uri = null; + return false; + } + return Uri.TryCreate(raw, UriKind.Absolute, out uri); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Msal/DataverseTokenCacheBinder.cs b/src/TALXIS.CLI.Platform.Dataverse/Msal/DataverseTokenCacheBinder.cs new file mode 100644 index 0000000..4152437 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Msal/DataverseTokenCacheBinder.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Client.Extensions.Msal; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Core.Vault; + +namespace TALXIS.CLI.Platform.Dataverse.Msal; + +/// +/// Holds the process-wide for the MSAL token +/// cache file (txc.msal.tokens.v1.dat). Keeping this as a DI singleton +/// is critical on macOS: every new helper instantiation is an additional +/// Keychain prompt (see session/files/keychain-prompt-research.md). +/// +/// +/// This binder is distinct from : +/// generic secrets (client secret / PAT / cert password) and refresh tokens +/// live in different cache files with different lifetimes, blast radiuses, +/// and plaintext-fallback consent. Same cache-helper pattern, different file. +/// +public sealed class DataverseTokenCacheBinder +{ + private readonly MsalCacheHelper _helper; + + /// Diagnostic hook for tests. + internal MsalCacheHelper Helper => _helper; + + public bool UsesPlaintextFallback { get; } + + private DataverseTokenCacheBinder(MsalCacheHelper helper, bool usesPlaintextFallback) + { + _helper = helper; + UsesPlaintextFallback = usesPlaintextFallback; + } + + /// + /// Attach the shared MSAL token cache to a newly-built public- or + /// confidential-client application. Safe to call many times across clients + /// — the underlying helper is shared. + /// + public void Attach(Microsoft.Identity.Client.ITokenCache cache) + { + ArgumentNullException.ThrowIfNull(cache); + _helper.RegisterCache(cache); + } + + public static async Task CreateAsync( + ConfigPaths paths, + IEnvironmentReader env, + ILogger? logger = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(env); + logger ??= NullLogger.Instance; + + var options = VaultOptions.MsalTokenCache(env); + var helper = await MsalCacheHelperFactory.CreateAsync(options, paths, logger, ct).ConfigureAwait(false); + return new DataverseTokenCacheBinder(helper, options.UsePlaintextFallback); + } + + /// Test-only factory that accepts an explicit . + internal static async Task CreateForTestingAsync( + VaultOptions options, + ConfigPaths paths, + ILogger? logger = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(paths); + logger ??= NullLogger.Instance; + + var helper = await MsalCacheHelperFactory.CreateAsync(options, paths, logger, ct).ConfigureAwait(false); + return new DataverseTokenCacheBinder(helper, options.UsePlaintextFallback); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Msal/FederatedAssertionCallbacks.cs b/src/TALXIS.CLI.Platform.Dataverse/Msal/FederatedAssertionCallbacks.cs new file mode 100644 index 0000000..cf8f912 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Msal/FederatedAssertionCallbacks.cs @@ -0,0 +1,215 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TALXIS.CLI.Core.Resolution; + +namespace TALXIS.CLI.Platform.Dataverse.Msal; + +/// +/// Ready-made factories for the three +/// federation providers we target in v1: +/// +/// +/// Azure DevOps (ADO) pipelines — same contract as pac CLI's +/// --azureDevOpsFederated flag. Reads an OIDC issuance URL + +/// bearer token from env vars, POSTs to idp/oidctoken and +/// parses the oidcToken JWT from the response. See discussions 884 / 906 on +/// microsoft/powerplatform-build-tools. +/// +/// +/// GitHub Actions — standard ACTIONS_ID_TOKEN_REQUEST_URL + +/// ACTIONS_ID_TOKEN_REQUEST_TOKEN pair with audience +/// query parameter. +/// +/// +/// Workload-identity-file — az-cli / AKS convention: +/// AZURE_FEDERATED_TOKEN_FILE points at a file whose contents are +/// the JWT. +/// +/// +/// +/// +/// All three flavours return raw JWTs that MSAL then exchanges for Entra +/// access tokens via WithClientAssertion on the confidential client. +/// No secrets are written to disk and the env vars are never logged (the +/// TOKEN env var is a short-lived bearer). +/// +public static class FederatedAssertionCallbacks +{ + /// pac-compatible ADO OIDC-URL env var. Also accepts PAC_* for drop-in parity. + public const string AdoRequestUrlVar = "TXC_ADO_ID_TOKEN_REQUEST_URL"; + public const string AdoRequestUrlVarLegacy = "PAC_ADO_ID_TOKEN_REQUEST_URL"; + + /// pac-compatible ADO OIDC-bearer env var (System.AccessToken). + public const string AdoRequestTokenVar = "TXC_ADO_ID_TOKEN_REQUEST_TOKEN"; + public const string AdoRequestTokenVarLegacy = "PAC_ADO_ID_TOKEN_REQUEST_TOKEN"; + + /// GitHub Actions OIDC standard env vars. + public const string GitHubRequestUrlVar = "ACTIONS_ID_TOKEN_REQUEST_URL"; + public const string GitHubRequestTokenVar = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"; + + /// Workload identity file (az-cli / AKS). File content is the JWT. + public const string FederatedTokenFileVar = "AZURE_FEDERATED_TOKEN_FILE"; + + /// + /// Returns the first callback whose env vars are populated, in priority: + /// ADO → GitHub Actions → federated-token file. Throws + /// when none of them are set — + /// callers must handle that case explicitly (e.g. prompt or fail fast). + /// + public static ClientAssertionCallback AutoSelect( + IEnvironmentReader env, + HttpClient? http = null, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(env); + + if (HasAny(env, AdoRequestUrlVar, AdoRequestUrlVarLegacy)) + return ForAzureDevOps(env, http, logger); + if (!string.IsNullOrEmpty(env.Get(GitHubRequestUrlVar))) + return ForGitHubActions(env, http, logger); + if (!string.IsNullOrEmpty(env.Get(FederatedTokenFileVar))) + return ForFederatedTokenFile(env, logger); + + throw new InvalidOperationException( + "No federated-credential source found. Set one of: " + + $"{AdoRequestUrlVar} (+ {AdoRequestTokenVar}), " + + $"{GitHubRequestUrlVar} (+ {GitHubRequestTokenVar}), " + + $"or {FederatedTokenFileVar}."); + } + + /// + /// Azure DevOps pipelines federation. POSTs to + /// {TXC_ADO_ID_TOKEN_REQUEST_URL} with + /// Authorization: Bearer {TXC_ADO_ID_TOKEN_REQUEST_TOKEN}, + /// parses the oidcToken / value field from the JSON body. + /// + public static ClientAssertionCallback ForAzureDevOps( + IEnvironmentReader env, + HttpClient? http = null, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(env); + var log = logger ?? NullLogger.Instance; + var urlEnv = env.Get(AdoRequestUrlVar) ?? env.Get(AdoRequestUrlVarLegacy); + var tokenEnv = env.Get(AdoRequestTokenVar) ?? env.Get(AdoRequestTokenVarLegacy); + + if (string.IsNullOrWhiteSpace(urlEnv)) + throw new InvalidOperationException($"{AdoRequestUrlVar} is not set."); + if (string.IsNullOrWhiteSpace(tokenEnv)) + throw new InvalidOperationException($"{AdoRequestTokenVar} is not set."); + + return ct => FetchAdoOidcAsync(new Uri(urlEnv), tokenEnv, http, log, ct); + } + + /// + /// GitHub Actions federation. Appends &audience=api://AzureADTokenExchange + /// to ACTIONS_ID_TOKEN_REQUEST_URL and GETs it with the bearer token + /// (this one really is a GET — only ADO's endpoint requires POST). + /// + public static ClientAssertionCallback ForGitHubActions( + IEnvironmentReader env, + HttpClient? http = null, + ILogger? logger = null, + string audience = "api://AzureADTokenExchange") + { + ArgumentNullException.ThrowIfNull(env); + var log = logger ?? NullLogger.Instance; + var url = env.Get(GitHubRequestUrlVar); + var token = env.Get(GitHubRequestTokenVar); + + if (string.IsNullOrWhiteSpace(url)) + throw new InvalidOperationException($"{GitHubRequestUrlVar} is not set."); + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException($"{GitHubRequestTokenVar} is not set."); + + var separator = url.Contains('?') ? '&' : '?'; + var full = new Uri($"{url}{separator}audience={Uri.EscapeDataString(audience)}"); + return ct => FetchGitHubOidcAsync(full, token, http, log, ct); + } + + /// + /// Reads a JWT from AZURE_FEDERATED_TOKEN_FILE. No HTTP call. + /// + public static ClientAssertionCallback ForFederatedTokenFile(IEnvironmentReader env, ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(env); + var path = env.Get(FederatedTokenFileVar); + if (string.IsNullOrWhiteSpace(path)) + throw new InvalidOperationException($"{FederatedTokenFileVar} is not set."); + + return async ct => + { + var jwt = (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).Trim(); + if (string.IsNullOrEmpty(jwt)) + throw new InvalidOperationException($"Federated token file '{path}' is empty."); + return jwt; + }; + } + + private static bool HasAny(IEnvironmentReader env, params string[] vars) + { + foreach (var v in vars) + if (!string.IsNullOrEmpty(env.Get(v))) return true; + return false; + } + + private static async Task FetchAdoOidcAsync( + Uri url, string bearer, HttpClient? http, ILogger log, CancellationToken ct) + { + var owned = http is null; + var client = http ?? new HttpClient(); + try + { + using var req = new HttpRequestMessage(HttpMethod.Post, url); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using var resp = await client.SendAsync(req, ct).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false); + + // ADO returns {"oidcToken": "...jwt..."}. + if (doc.RootElement.TryGetProperty("oidcToken", out var jwt) && jwt.ValueKind == JsonValueKind.String) + return jwt.GetString()!; + if (doc.RootElement.TryGetProperty("value", out var val) && val.ValueKind == JsonValueKind.String) + return val.GetString()!; + + throw new InvalidOperationException("ADO OIDC response did not contain an 'oidcToken' or 'value' field."); + } + finally + { + if (owned) client.Dispose(); + log.LogDebug("Fetched ADO OIDC assertion from {Url}.", url); + } + } + + private static async Task FetchGitHubOidcAsync( + Uri url, string bearer, HttpClient? http, ILogger log, CancellationToken ct) + { + var owned = http is null; + var client = http ?? new HttpClient(); + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using var resp = await client.SendAsync(req, ct).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false); + + // GitHub returns {"value": "...jwt...", "count": 1234}. + if (doc.RootElement.TryGetProperty("value", out var val) && val.ValueKind == JsonValueKind.String) + return val.GetString()!; + + throw new InvalidOperationException("GitHub Actions OIDC response did not contain a 'value' field."); + } + finally + { + if (owned) client.Dispose(); + log.LogDebug("Fetched GitHub OIDC assertion from {Url}.", url); + } + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Platforms/CmtImportJob.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/CmtImportJob.cs new file mode 100644 index 0000000..ef7053d --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/CmtImportJob.cs @@ -0,0 +1,16 @@ +using TALXIS.CLI.Platform.Xrm; + +namespace TALXIS.CLI.Platform.Dataverse.Platforms; + +/// +/// IPC envelope for the CMT data-import subprocess. Mirrors the shape of +/// 's IPC fields (profile/config/parent) +/// without polluting the lower-level record +/// (which is also used by in-process callers and tests that pass a +/// connection string). +/// +public sealed record CmtImportJob( + CmtImportRequest Request, + string ProfileId, + string? ConfigDirectory, + int ParentProcessId); diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/DeploymentFindingsAnalyzer.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/DeploymentFindingsAnalyzer.cs similarity index 99% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/DeploymentFindingsAnalyzer.cs rename to src/TALXIS.CLI.Platform.Dataverse/Platforms/DeploymentFindingsAnalyzer.cs index 2b7f58e..8dfcb78 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/DeploymentFindingsAnalyzer.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/DeploymentFindingsAnalyzer.cs @@ -1,6 +1,7 @@ +using TALXIS.CLI.Core.Platforms.Dataverse; using System.Text.RegularExpressions; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Platform.Dataverse.Platforms; /// /// Input to . Carries only structured records already diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/DeploymentSchema.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/DeploymentSchema.cs similarity index 89% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/DeploymentSchema.cs rename to src/TALXIS.CLI.Platform.Dataverse/Platforms/DeploymentSchema.cs index e0f6a12..7a5434a 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/DeploymentSchema.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/DeploymentSchema.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Platform.Dataverse.Platforms; /// /// Logical names of Dataverse entities used by the deployment domain diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/ImportJobReader.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/ImportJobReader.cs similarity index 97% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/ImportJobReader.cs rename to src/TALXIS.CLI.Platform.Dataverse/Platforms/ImportJobReader.cs index 16230ac..45788a3 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/ImportJobReader.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/ImportJobReader.cs @@ -1,11 +1,11 @@ -using TALXIS.CLI.Dataverse; +using TALXIS.CLI.Platform.Dataverse; using Microsoft.Crm.Sdk.Messages; using Microsoft.Extensions.Logging; using Microsoft.PowerPlatform.Dataverse.Client; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Platform.Dataverse.Platforms; /// /// Structured view of a row in the importjob table. All values are UTC. diff --git a/src/TALXIS.CLI.Platform.Dataverse/Platforms/LegacyAssemblyHostSubprocess.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/LegacyAssemblyHostSubprocess.cs new file mode 100644 index 0000000..a946fc5 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/LegacyAssemblyHostSubprocess.cs @@ -0,0 +1,536 @@ +using System.Diagnostics; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json; +using TALXIS.CLI.Platform.Dataverse.DependencyInjection; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Platform.Xrm; + +namespace TALXIS.CLI.Platform.Dataverse.Platforms; + +/// +/// Out-of-process host for runners that depend on the legacy-patched +/// Xrm.Tooling assemblies ( and +/// ). Spawning each run in a fresh process +/// keeps the AssemblyResolve probing + Cecil-patched assembly +/// redirects isolated from the main txc process. +/// +/// +/// Auth flows through the shared MSAL cache file referenced by +/// TXC_CONFIG_DIR; the parent primes the cache (see +/// ) before spawning, +/// and the child performs its own silent token acquisitions against the +/// same cache via . +/// +public static class LegacyAssemblyHostSubprocess +{ + private const string PackageDeployerCommand = "__txc_internal_package_deployer"; + private const string CmtImportCommand = "__txc_internal_cmt_import"; + private const string CleanupCommand = "__txc_internal_package_deployer_cleanup"; + private const string ConfigDirectoryEnvVar = "TXC_CONFIG_DIR"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + /// Child-process dispatcher. Called from Program.Main. + public static async Task TryRunAsync(string[] args) + { + if (args.Length == 0) + { + return null; + } + + return args[0] switch + { + CleanupCommand => await RunCleanupHelperAsync(args).ConfigureAwait(false), + PackageDeployerCommand => await RunPackageDeployerChildAsync(args).ConfigureAwait(false), + CmtImportCommand => await RunCmtImportChildAsync(args).ConfigureAwait(false), + _ => null, + }; + } + + /// Parent-side entry point for a Package Deployer run. + public static async Task RunPackageDeployerAsync( + PackageDeployerRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + string temporaryArtifactsDirectory = BuildTempDir("package-deployer-host"); + string? configDirectory = request.ConfigDirectory + ?? System.Environment.GetEnvironmentVariable(ConfigDirectoryEnvVar); + + PackageDeployerRequest effectiveRequest = request with + { + TemporaryArtifactsDirectory = temporaryArtifactsDirectory, + ParentProcessId = System.Environment.ProcessId, + ConfigDirectory = configDirectory + }; + + return await RunJobAsync( + PackageDeployerCommand, + effectiveRequest, + temporaryArtifactsDirectory, + cancellationToken).ConfigureAwait(false); + } + + /// Parent-side entry point for a CMT data-import run. + public static async Task RunCmtImportAsync( + CmtImportRequest request, string profileId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(profileId); + + string? configDirectory = System.Environment.GetEnvironmentVariable(ConfigDirectoryEnvVar); + + CmtImportJob envelope = new( + request, + profileId, + configDirectory, + System.Environment.ProcessId); + + // CMT does not produce the same extracted-package host directory + // Package Deployer does, so there is nothing to clean up after the + // run beyond the coordinator directory itself. + return await RunJobAsync( + CmtImportCommand, + envelope, + temporaryArtifactsDirectory: null, + cancellationToken).ConfigureAwait(false); + } + + internal static void TryDeleteDirectory(string? path) + { + if (string.IsNullOrEmpty(path)) return; + + const int maxAttempts = 5; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + if (!Directory.Exists(path)) + { + return; + } + + foreach (string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(path, recursive: true); + TryDeleteEmptyParentDirectories(path); + return; + } + catch (DirectoryNotFoundException) + { + return; + } + catch (IOException) when (attempt < maxAttempts) + { + Thread.Sleep(TimeSpan.FromMilliseconds(250 * attempt)); + } + catch (UnauthorizedAccessException) when (attempt < maxAttempts) + { + Thread.Sleep(TimeSpan.FromMilliseconds(250 * attempt)); + } + } + } + + private static async Task RunJobAsync( + string commandName, + TRequest request, + string? temporaryArtifactsDirectory, + CancellationToken cancellationToken) + { + string coordinatorDirectory = BuildTempDir("package-deployer-process"); + Directory.CreateDirectory(coordinatorDirectory); + + string requestPath = Path.Combine(coordinatorDirectory, "request.json"); + string resultPath = Path.Combine(coordinatorDirectory, "result.json"); + + try + { + await WriteJsonAsync(requestPath, request).ConfigureAwait(false); + // Even though requests no longer carry secrets, keep coordinator + // files readable only by the current user. On Windows per-user + // %TEMP% ACLs suffice; on Unix we chmod 600. + TrySetOwnerReadWriteOnly(requestPath); + + using Process process = StartSubprocess(commandName, requestPath, resultPath); + using Process cleanupHelper = StartCleanupHelper( + coordinatorDirectory, + temporaryArtifactsDirectory, + process.Id); + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + TryKillProcessTree(process); + TryKillProcess(cleanupHelper); + await WaitForExitIgnoringErrorsAsync(process).ConfigureAwait(false); + await WaitForExitIgnoringErrorsAsync(cleanupHelper).ConfigureAwait(false); + throw; + } + + if (!File.Exists(resultPath)) + { + throw new InvalidOperationException($"Legacy-assembly host subprocess ('{commandName}') did not produce a result."); + } + + return await ReadJsonAsync(resultPath).ConfigureAwait(false); + } + finally + { + TryDeleteDirectory(temporaryArtifactsDirectory); + TryDeleteDirectory(coordinatorDirectory); + } + } + + private static async Task RunPackageDeployerChildAsync(string[] args) + { + if (args.Length != 3) + { + return null; + } + + string requestPath = args[1]; + string resultPath = args[2]; + + PackageDeployerResult result; + try + { + PackageDeployerRequest request = await ReadJsonAsync(requestPath).ConfigureAwait(false); + + ApplyConfigDirectory(request.ConfigDirectory); + TxcServicesBootstrap.EnsureInitialized(); + + using CancellationTokenSource parentWatcher = RegisterParentExitWatcher(request.ParentProcessId); + try + { + var (envUrl, tokenProvider) = await DataverseCommandBridge + .BuildTokenProviderAsync(request.ProfileId, parentWatcher.Token) + .ConfigureAwait(false); + + PackageDeployerRunner runner = new(); + result = await runner.RunAsync(request, envUrl, tokenProvider, parentWatcher.Token).ConfigureAwait(false); + } + finally + { + parentWatcher.Cancel(); + } + } + catch (Exception ex) + { + result = new PackageDeployerResult(false, ex.Message, null, null, null); + } + + await WriteJsonAsync(resultPath, result).ConfigureAwait(false); + return result.Succeeded ? 0 : 1; + } + + private static async Task RunCmtImportChildAsync(string[] args) + { + if (args.Length != 3) + { + return null; + } + + string requestPath = args[1]; + string resultPath = args[2]; + + CmtImportResult result; + try + { + CmtImportJob envelope = await ReadJsonAsync(requestPath).ConfigureAwait(false); + + ApplyConfigDirectory(envelope.ConfigDirectory); + TxcServicesBootstrap.EnsureInitialized(); + + using CancellationTokenSource parentWatcher = RegisterParentExitWatcher(envelope.ParentProcessId); + try + { + var (envUrl, tokenProvider) = await DataverseCommandBridge + .BuildTokenProviderAsync(envelope.ProfileId, parentWatcher.Token) + .ConfigureAwait(false); + + CmtImportRunner runner = new(); + result = await runner.RunAsync(envelope.Request, envUrl, tokenProvider, parentWatcher.Token).ConfigureAwait(false); + } + finally + { + parentWatcher.Cancel(); + } + } + catch (Exception ex) + { + result = new CmtImportResult(false, ex.Message); + } + + await WriteJsonAsync(resultPath, result).ConfigureAwait(false); + return result.Succeeded ? 0 : 1; + } + + private static void ApplyConfigDirectory(string? configDirectory) + { + if (!string.IsNullOrWhiteSpace(configDirectory)) + { + System.Environment.SetEnvironmentVariable(ConfigDirectoryEnvVar, configDirectory); + } + } + + private static string BuildTempDir(string subPath) => + Path.Combine(Path.GetTempPath(), "txc", subPath, Guid.NewGuid().ToString("N")); + + private static void TryDeleteEmptyParentDirectories(string deletedPath) + { + string stopPath = Path.Combine(Path.GetTempPath(), "txc"); + DirectoryInfo? current = Directory.GetParent(deletedPath); + + while (current is not null && + current.Exists && + !string.Equals(current.FullName, stopPath, StringComparison.OrdinalIgnoreCase)) + { + if (current.EnumerateFileSystemInfos().Any()) + { + return; + } + + try + { + Directory.Delete(current.FullName, recursive: false); + } + catch + { + return; + } + + current = current.Parent; + } + } + + private static Process StartSubprocess(string commandName, string requestPath, string resultPath) + { + string processPath = System.Environment.ProcessPath + ?? throw new InvalidOperationException("Could not resolve the current txc process path."); + + string? entryAssemblyPath = Assembly.GetEntryAssembly()?.Location; + ProcessStartInfo startInfo = new() + { + UseShellExecute = false, + WorkingDirectory = System.Environment.CurrentDirectory + }; + + if (IsDotnetHost(processPath)) + { + if (string.IsNullOrWhiteSpace(entryAssemblyPath)) + { + throw new InvalidOperationException("Could not resolve the txc entry assembly path."); + } + + startInfo.FileName = processPath; + startInfo.ArgumentList.Add(entryAssemblyPath); + } + else + { + startInfo.FileName = processPath; + } + + startInfo.ArgumentList.Add(commandName); + startInfo.ArgumentList.Add(requestPath); + startInfo.ArgumentList.Add(resultPath); + + return Process.Start(startInfo) + ?? throw new InvalidOperationException($"Failed to start the '{commandName}' subprocess."); + } + + private static Process StartCleanupHelper(string coordinatorDirectory, string? temporaryArtifactsDirectory, int childProcessId) + { + string processPath = System.Environment.ProcessPath + ?? throw new InvalidOperationException("Could not resolve the current txc process path."); + + string? entryAssemblyPath = Assembly.GetEntryAssembly()?.Location; + ProcessStartInfo startInfo = new() + { + UseShellExecute = false, + WorkingDirectory = System.Environment.CurrentDirectory, + CreateNoWindow = true + }; + + if (IsDotnetHost(processPath)) + { + if (string.IsNullOrWhiteSpace(entryAssemblyPath)) + { + throw new InvalidOperationException("Could not resolve the txc entry assembly path."); + } + + startInfo.FileName = processPath; + startInfo.ArgumentList.Add(entryAssemblyPath); + } + else + { + startInfo.FileName = processPath; + } + + startInfo.ArgumentList.Add(CleanupCommand); + startInfo.ArgumentList.Add(coordinatorDirectory); + // Empty string sentinel for "no temp-artifacts directory" — argv lists + // can't carry nulls, and the cleanup helper skips empty paths. + startInfo.ArgumentList.Add(temporaryArtifactsDirectory ?? string.Empty); + startInfo.ArgumentList.Add(childProcessId.ToString(CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(System.Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + + return Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start the Package Deployer cleanup helper."); + } + + private static bool IsDotnetHost(string processPath) + { + string fileName = Path.GetFileNameWithoutExtension(processPath); + return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase); + } + + private static void TryKillProcessTree(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + catch (NotSupportedException) + { + TryKillProcess(process); + } + } + + private static void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(); + } + } + catch (InvalidOperationException) + { + } + } + + private static async Task WaitForExitIgnoringErrorsAsync(Process process) + { + try + { + await process.WaitForExitAsync().ConfigureAwait(false); + } + catch (InvalidOperationException) + { + } + } + + private static async Task RunCleanupHelperAsync(string[] args) + { + if (args.Length != 5) + { + return 1; + } + + string coordinatorDirectory = args[1]; + string temporaryArtifactsDirectory = args[2]; + int childProcessId = int.Parse(args[3], CultureInfo.InvariantCulture); + int parentProcessId = int.Parse(args[4], CultureInfo.InvariantCulture); + + await WaitForProcessExitAsync(childProcessId).ConfigureAwait(false); + TryDeleteDirectory(temporaryArtifactsDirectory); + + await WaitForProcessExitAsync(parentProcessId).ConfigureAwait(false); + TryDeleteDirectory(coordinatorDirectory); + TryDeleteDirectory(temporaryArtifactsDirectory); + return 0; + } + + private static CancellationTokenSource RegisterParentExitWatcher(int parentProcessId) + { + CancellationTokenSource cts = new(); + if (parentProcessId <= 0) + { + return cts; + } + + _ = Task.Run(async () => + { + await WaitForProcessExitAsync(parentProcessId).ConfigureAwait(false); + if (!cts.IsCancellationRequested) + { + System.Environment.FailFast("Parent txc process exited while a legacy-assembly host subprocess was still running."); + } + }); + + return cts; + } + + private static async Task WaitForProcessExitAsync(int processId) + { + if (processId <= 0) + { + return; + } + + try + { + using Process process = Process.GetProcessById(processId); + await process.WaitForExitAsync().ConfigureAwait(false); + } + catch (ArgumentException) + { + } + catch (InvalidOperationException) + { + } + } + + private static async Task ReadJsonAsync(string path) + { + await using FileStream stream = File.OpenRead(path); + T? value = await JsonSerializer.DeserializeAsync(stream, JsonOptions).ConfigureAwait(false); + return value ?? throw new InvalidOperationException($"Could not deserialize '{path}'."); + } + + private static async Task WriteJsonAsync(string path, T value) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await using FileStream stream = File.Create(path); + await JsonSerializer.SerializeAsync(stream, value, JsonOptions).ConfigureAwait(false); + } + + private static void TrySetOwnerReadWriteOnly(string path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Per-user %TEMP% ACLs already restrict access. Explicit ACL + // tightening via System.Security.AccessControl would add a Windows + // SDK dependency for negligible benefit. + return; + } + + try + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (PlatformNotSupportedException) + { + // Running on a platform where Unix modes are meaningless. + } + catch (IOException) + { + // Filesystem does not honor mode bits (e.g. some mounted shares) — + // best-effort only; the request file contains no secrets. + } + } +} diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageHistoryReader.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/PackageHistoryReader.cs similarity index 90% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageHistoryReader.cs rename to src/TALXIS.CLI.Platform.Dataverse/Platforms/PackageHistoryReader.cs index 7e82f84..fbfc15a 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageHistoryReader.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/PackageHistoryReader.cs @@ -1,30 +1,11 @@ -using TALXIS.CLI.Dataverse; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; using Microsoft.Extensions.Logging; using Microsoft.PowerPlatform.Dataverse.Client; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; - -/// -/// Structured view of a row in the packagehistory table. -/// All values are UTC. -/// -public sealed record PackageHistoryRecord( - Guid Id, - string? Name, - string? Status, - string? Stage, - DateTime? StartedAtUtc, - DateTime? CompletedAtUtc, - Guid? OperationId, - string? Message, - /// - /// PD's per-run correlation GUID. Set as x-ms-client-session-id on every SDK call, - /// so Dataverse records it as asyncoperation.correlationid for each import job. - /// Use with for exact join. - /// - Guid? CorrelationId = null); +namespace TALXIS.CLI.Platform.Dataverse.Platforms; /// /// Reader for the packagehistory table (Package Deployer run records). diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageHistoryWriter.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/PackageHistoryWriter.cs similarity index 98% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageHistoryWriter.cs rename to src/TALXIS.CLI.Platform.Dataverse/Platforms/PackageHistoryWriter.cs index 1e9b5b5..9c316a0 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageHistoryWriter.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/PackageHistoryWriter.cs @@ -1,3 +1,4 @@ +using TALXIS.CLI.Core.Platforms.Dataverse; using Microsoft.Extensions.Logging; using Microsoft.Crm.Sdk.Messages; using Microsoft.PowerPlatform.Dataverse.Client; @@ -6,7 +7,7 @@ using Microsoft.Xrm.Sdk.Metadata; using Microsoft.Xrm.Sdk.Query; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Platform.Dataverse.Platforms; public sealed record PackageHistoryStatusCodes( int? InProcessStatus, diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageImportConfigReader.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/PackageImportConfigReader.cs similarity index 97% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageImportConfigReader.cs rename to src/TALXIS.CLI.Platform.Dataverse/Platforms/PackageImportConfigReader.cs index b066326..5ea083b 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/PackageImportConfigReader.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/PackageImportConfigReader.cs @@ -1,7 +1,8 @@ using System.IO.Compression; using System.Xml.Linq; +using TALXIS.CLI.Core.Platforms.Packaging; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Platform.Dataverse.Platforms; /// /// Reads package ImportConfig.xml and resolves solution unique names in import order. @@ -54,7 +55,7 @@ public async Task> ReadSolutionUniqueNamesInImportOrderAsy { if (installResult.UsesTemporaryWorkingDirectory) { - PackageDeployerSubprocess.TryDeleteDirectory(installResult.WorkingDirectory); + LegacyAssemblyHostSubprocess.TryDeleteDirectory(installResult.WorkingDirectory); } } } diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionHistoryMappings.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionHistoryMappings.cs similarity index 95% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionHistoryMappings.cs rename to src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionHistoryMappings.cs index 146241c..dcd492b 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionHistoryMappings.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionHistoryMappings.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Platform.Dataverse.Platforms; /// /// Static mappings for Dataverse option-set codes that appear in solution-history / import-job diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionHistoryReader.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionHistoryReader.cs similarity index 93% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionHistoryReader.cs rename to src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionHistoryReader.cs index 23b457f..63ff8b0 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionHistoryReader.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionHistoryReader.cs @@ -1,34 +1,11 @@ -using TALXIS.CLI.Dataverse; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; using Microsoft.Extensions.Logging; using Microsoft.PowerPlatform.Dataverse.Client; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; - -/// -/// Structured view of a row in msdyn_solutionhistory. Operation / suboperation codes -/// are mapped via . values are UTC. -/// -public sealed record SolutionHistoryRecord( - Guid Id, - string? SolutionName, - string? SolutionVersion, - string? PackageName, - int? OperationCode, - string OperationLabel, - int? SuboperationCode, - string SuboperationLabel, - bool? OverwriteUnmanagedCustomizations, - DateTime? StartedAtUtc, - DateTime? CompletedAtUtc, - string? Result, - /// - /// Value of msdyn_activityid — the asyncoperation.asyncoperationid of the - /// platform's internal import job. Used for exact correlation with packagehistory via - /// . - /// - Guid? ActivityId = null); +namespace TALXIS.CLI.Platform.Dataverse.Platforms; /// /// Reader for msdyn_solutionhistory. The virtual entity rejects arbitrary diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionImporter.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionImporter.cs similarity index 90% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionImporter.cs rename to src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionImporter.cs index 44636fc..7918f8b 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionImporter.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionImporter.cs @@ -6,44 +6,11 @@ using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Messages; using Microsoft.Xrm.Sdk.Query; -using TALXIS.CLI.Dataverse; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; +using SolutionInfo = TALXIS.CLI.Core.Platforms.Dataverse.SolutionInfo; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; - -/// -/// Information about a Dataverse solution's identity extracted from its ZIP manifest -/// or retrieved from the target environment. -/// -public sealed record SolutionInfo(string UniqueName, Version Version, bool Managed); - -public enum SolutionImportPath -{ - /// Target environment has no solution with this unique name. - Install, - /// Plain import over an existing solution (unmanaged, or managed without single-step upgrade). - Update, - /// Single-step upgrade (StageAndUpgradeRequest) over an existing managed solution. - Upgrade, -} - -public sealed record SolutionImportOptions( - bool StageAndUpgrade, - bool ForceOverwrite, - bool PublishWorkflows, - bool SkipDependencyCheck, - bool SkipLowerVersion, - bool Async); - -public sealed record SolutionImportResult( - SolutionImportPath Path, - SolutionInfo Source, - SolutionInfo? ExistingTarget, - Guid ImportJobId, - Guid? AsyncOperationId, - DateTime StartedAtUtc, - DateTime? CompletedAtUtc, - bool SmartDiffExpected, - string Status); +namespace TALXIS.CLI.Platform.Dataverse.Platforms; /// /// Orchestrates solution imports against Dataverse via the modern . diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionUninstaller.cs b/src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionUninstaller.cs similarity index 91% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionUninstaller.cs rename to src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionUninstaller.cs index 04580cd..28ce1cb 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionUninstaller.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Platforms/SolutionUninstaller.cs @@ -1,25 +1,12 @@ using Microsoft.Extensions.Logging; -using TALXIS.CLI.Dataverse; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; using Microsoft.PowerPlatform.Dataverse.Client; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Messages; using Microsoft.Xrm.Sdk.Query; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; - -public enum SolutionUninstallStatus -{ - Success, - NotFound, - Ambiguous, - Failed, -} - -public sealed record SolutionUninstallOutcome( - string SolutionName, - Guid? SolutionId, - SolutionUninstallStatus Status, - string Message); +namespace TALXIS.CLI.Platform.Dataverse.Platforms; /// /// Uninstalls solutions from Dataverse by unique name. diff --git a/src/TALXIS.CLI.Platform.Dataverse/Runtime/DataverseAccessTokenService.cs b/src/TALXIS.CLI.Platform.Dataverse/Runtime/DataverseAccessTokenService.cs new file mode 100644 index 0000000..7391714 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Runtime/DataverseAccessTokenService.cs @@ -0,0 +1,181 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Client; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.Dataverse.Msal; +using TALXIS.CLI.Platform.Dataverse.Scopes; +using TALXIS.CLI.Core.Resolution; + +namespace TALXIS.CLI.Platform.Dataverse.Runtime; + +/// +/// Default . Drives MSAL via the +/// shared + token-cache binder so +/// all credential kinds go through one code path for authority selection, +/// scope construction, and cache attachment. +/// +public sealed class DataverseAccessTokenService : IDataverseAccessTokenService +{ + private readonly DataverseMsalClientFactory _clientFactory; + private readonly DataverseTokenCacheBinder _cacheBinder; + private readonly ICredentialVault _vault; + private readonly IEnvironmentReader _env; + private readonly ILogger _logger; + + public DataverseAccessTokenService( + DataverseMsalClientFactory clientFactory, + DataverseTokenCacheBinder cacheBinder, + ICredentialVault vault, + IEnvironmentReader env, + ILogger? logger = null) + { + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + _cacheBinder = cacheBinder ?? throw new ArgumentNullException(nameof(cacheBinder)); + _vault = vault ?? throw new ArgumentNullException(nameof(vault)); + _env = env ?? throw new ArgumentNullException(nameof(env)); + _logger = logger ?? NullLogger.Instance; + } + + public async Task AcquireAsync(TALXIS.CLI.Core.Model.Connection connection, Credential credential, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + + if (string.IsNullOrWhiteSpace(connection.EnvironmentUrl)) + throw new InvalidOperationException($"Dataverse connection '{connection.Id}' is missing EnvironmentUrl."); + if (!Uri.TryCreate(connection.EnvironmentUrl, UriKind.Absolute, out var envUri)) + throw new InvalidOperationException($"Dataverse connection '{connection.Id}' EnvironmentUrl '{connection.EnvironmentUrl}' is not a valid absolute URI."); + + return await AcquireForResourceAsync(connection, credential, envUri, ct).ConfigureAwait(false); + } + + public async Task AcquireForResourceAsync( + TALXIS.CLI.Core.Model.Connection connection, + Credential credential, + Uri resourceUri, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(credential); + ArgumentNullException.ThrowIfNull(resourceUri); + if (!resourceUri.IsAbsoluteUri) + throw new ArgumentException($"Resource URI '{resourceUri}' must be absolute.", nameof(resourceUri)); + + var scope = DataverseScope.BuildDefault(resourceUri); + + return credential.Kind switch + { + CredentialKind.InteractiveBrowser => await AcquirePublicClientSilentAsync(connection, credential, scope, ct).ConfigureAwait(false), + CredentialKind.ClientSecret => await AcquireClientSecretAsync(connection, credential, scope, ct).ConfigureAwait(false), + CredentialKind.ClientCertificate => await AcquireClientCertificateAsync(connection, credential, scope, ct).ConfigureAwait(false), + CredentialKind.WorkloadIdentityFederation => await AcquireFederatedAsync(connection, credential, scope, ct).ConfigureAwait(false), + CredentialKind.DeviceCode or CredentialKind.ManagedIdentity or CredentialKind.AzureCli or CredentialKind.Pat => + throw new NotSupportedException( + $"Credential kind {credential.Kind} is reserved but not yet wired for Dataverse token acquisition in this release. " + + "Use InteractiveBrowser, ClientSecret, ClientCertificate, or WorkloadIdentityFederation."), + _ => throw new NotSupportedException($"Unknown credential kind: {credential.Kind}"), + }; + } + + private async Task AcquirePublicClientSilentAsync( + TALXIS.CLI.Core.Model.Connection connection, Credential credential, string scope, CancellationToken ct) + { + var app = _clientFactory.BuildPublicClient(connection); + _cacheBinder.Attach(app.UserTokenCache); + + var accounts = await app.GetAccountsAsync().ConfigureAwait(false); + // Prefer the account whose UPN matches the credential alias (case-insensitive). + var account = accounts.FirstOrDefault(a => + string.Equals(a.Username, credential.Id, StringComparison.OrdinalIgnoreCase)) + ?? accounts.FirstOrDefault(); + + if (account is null) + { + throw new InvalidOperationException( + $"No cached sign-in found for credential '{credential.Id}'. " + + "Run 'txc config auth login' and retry."); + } + + try + { + var result = await app + .AcquireTokenSilent(new[] { scope }, account) + .ExecuteAsync(ct) + .ConfigureAwait(false); + return result.AccessToken; + } + catch (MsalUiRequiredException ex) + { + throw new InvalidOperationException( + $"Cached token for '{credential.Id}' expired or is missing consent. " + + "Run 'txc config auth login' and retry.", ex); + } + } + + private async Task AcquireClientSecretAsync( + TALXIS.CLI.Core.Model.Connection connection, Credential credential, string scope, CancellationToken ct) + { + if (credential.SecretRef is null) + throw new InvalidOperationException($"Credential '{credential.Id}' (ClientSecret) has no SecretRef."); + + var secret = await _vault.GetSecretAsync(credential.SecretRef, ct).ConfigureAwait(false); + if (string.IsNullOrEmpty(secret)) + throw new InvalidOperationException( + $"Credential '{credential.Id}' (ClientSecret) is missing its secret in the vault. " + + $"Re-run 'txc config auth add-service-principal' to repopulate."); + + var material = new ConfidentialClientMaterial { ClientSecret = secret }; + var app = _clientFactory.BuildConfidentialClient(connection, credential, material); + var result = await app + .AcquireTokenForClient(new[] { scope }) + .ExecuteAsync(ct) + .ConfigureAwait(false); + return result.AccessToken; + } + + private async Task AcquireClientCertificateAsync( + TALXIS.CLI.Core.Model.Connection connection, Credential credential, string scope, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(credential.CertificatePath)) + throw new InvalidOperationException($"Credential '{credential.Id}' (ClientCertificate) has no CertificatePath."); + if (!File.Exists(credential.CertificatePath)) + throw new InvalidOperationException($"Credential '{credential.Id}' certificate file not found: {credential.CertificatePath}"); + + string? password = null; + if (credential.SecretRef is not null) + password = await _vault.GetSecretAsync(credential.SecretRef, ct).ConfigureAwait(false); + + var cert = string.IsNullOrEmpty(password) + ? X509CertificateLoader.LoadPkcs12FromFile(credential.CertificatePath, null) + : X509CertificateLoader.LoadPkcs12FromFile(credential.CertificatePath, password); + + try + { + var material = new ConfidentialClientMaterial { Certificate = cert }; + var app = _clientFactory.BuildConfidentialClient(connection, credential, material); + var result = await app + .AcquireTokenForClient(new[] { scope }) + .ExecuteAsync(ct) + .ConfigureAwait(false); + return result.AccessToken; + } + finally + { + cert.Dispose(); + } + } + + private async Task AcquireFederatedAsync( + TALXIS.CLI.Core.Model.Connection connection, Credential credential, string scope, CancellationToken ct) + { + var callback = FederatedAssertionCallbacks.AutoSelect(_env, logger: _logger); + var material = new ConfidentialClientMaterial { AssertionCallback = callback }; + var app = _clientFactory.BuildConfidentialClient(connection, credential, material); + var result = await app + .AcquireTokenForClient(new[] { scope }) + .ExecuteAsync(ct) + .ConfigureAwait(false); + return result.AccessToken; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Runtime/DataverseCommandBridge.cs b/src/TALXIS.CLI.Platform.Dataverse/Runtime/DataverseCommandBridge.cs new file mode 100644 index 0000000..3a820f8 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Runtime/DataverseCommandBridge.cs @@ -0,0 +1,144 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.Dataverse; + +namespace TALXIS.CLI.Platform.Dataverse.Runtime; + +/// +/// Shared helper that every refactored Dataverse leaf command uses to turn +/// a --profile string (or null => resolver defaults / TXC_PROFILE) +/// into a ready-to-use . +/// +public static class DataverseCommandBridge +{ + public static async Task ConnectAsync(string? profileName, CancellationToken ct) + { + var resolver = TxcServices.Get(); + var factory = TxcServices.Get(); + var context = await resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + return await factory.ConnectAsync(context, ct).ConfigureAwait(false); + } + + /// + /// Validates the active profile + primes the MSAL cache by acquiring a + /// token once. Used by parent commands before spawning a subprocess so + /// that auth failures (e.g. the "Run txc config auth login" + /// message wrapping ) + /// surface in the parent process — which has a TTY and the main log + /// sinks — rather than inside the child. + /// + /// + /// Success does not guarantee the child will succeed (refresh tokens can + /// expire between calls), but in practice the gap is milliseconds. + /// + public static async Task PrimeTokenAsync(string? profileName, CancellationToken ct) + { + var resolver = TxcServices.Get(); + var svc = TxcServices.Get(); + var context = await resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + + ValidateDataverseProfile(context.Connection); + var envUri = ParseEnvironmentUrl(context.Connection); + + _ = await svc.AcquireForResourceAsync(context.Connection, context.Credential, envUri, ct).ConfigureAwait(false); + + return new PrimedProfile(context.Connection, context.Credential, envUri); + } + + /// + /// Builds a per-call token provider suitable for + /// Microsoft.PowerPlatform.Dataverse.Client.ServiceClient's + /// Func<string, Task<string>> constructor. + /// + /// + /// The provider honours the resource URI the SDK asks for — which may + /// be a canonicalized/redirected org URL rather than the + /// Connection.EnvironmentUrl we resolved from the profile. See + /// . + /// Cancellation of the returned provider follows + /// ; callers that need different per-call + /// cancellation should wrap the returned delegate themselves. + /// + public static async Task<(Uri EnvironmentUrl, Func> TokenProvider)> BuildTokenProviderAsync( + string? profileName, CancellationToken ct) + { + var resolver = TxcServices.Get(); + var svc = TxcServices.Get(); + var context = await resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + + ValidateDataverseProfile(context.Connection); + var envUri = ParseEnvironmentUrl(context.Connection); + + var connection = context.Connection; + var credential = context.Credential; + + Func> provider = async (resourceUriString) => + { + if (string.IsNullOrWhiteSpace(resourceUriString)) + throw new ArgumentException("Dataverse token provider received an empty resource URI.", nameof(resourceUriString)); + if (!Uri.TryCreate(resourceUriString, UriKind.Absolute, out var resourceUri)) + throw new ArgumentException($"Dataverse token provider received an invalid resource URI: '{resourceUriString}'.", nameof(resourceUriString)); + return await svc.AcquireForResourceAsync(connection, credential, resourceUri, ct).ConfigureAwait(false); + }; + + return (envUri, provider); + } + + /// + /// Transitional helper for callers that still need a Dataverse + /// connection string (tests, and any legacy code path not yet migrated + /// to the token-provider callback). + /// + /// + /// Supports only . Every other + /// kind — including — + /// must use + the + /// ServiceClient(Uri, Func<string, Task<string>>, ...) + /// constructor instead; attempting to inline an interactive session + /// into a connection string is by design impossible. + /// + public static async Task BuildConnectionStringAsync(string? profileName, CancellationToken ct) + { + var resolver = TxcServices.Get(); + var context = await resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + ValidateDataverseProfile(context.Connection); + + var url = context.Connection.EnvironmentUrl!.TrimEnd('/'); + var cred = context.Credential; + + if (cred.Kind != CredentialKind.ClientSecret) + throw new NotSupportedException( + $"Credential kind '{cred.Kind}' cannot be serialized into a Dataverse connection string. " + + "Use a token-provider path (BuildTokenProviderAsync) instead."); + + if (string.IsNullOrWhiteSpace(cred.ApplicationId)) + throw new InvalidOperationException($"Credential '{cred.Id}' is missing ApplicationId."); + if (cred.SecretRef is null) + throw new InvalidOperationException($"Credential '{cred.Id}' has no SecretRef for its client secret."); + var vault = TxcServices.Get(); + var secret = await vault.GetSecretAsync(cred.SecretRef, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Vault could not return a client secret for credential '{cred.Id}'."); + return $"AuthType=ClientSecret;Url={url};ClientId={cred.ApplicationId};ClientSecret={secret}"; + } + + private static void ValidateDataverseProfile(TALXIS.CLI.Core.Model.Connection connection) + { + if (connection.Provider != ProviderKind.Dataverse) + throw new InvalidOperationException( + $"Connection '{connection.Id}' has provider {connection.Provider}, expected {ProviderKind.Dataverse}."); + if (string.IsNullOrWhiteSpace(connection.EnvironmentUrl)) + throw new InvalidOperationException($"Dataverse connection '{connection.Id}' is missing EnvironmentUrl."); + } + + private static Uri ParseEnvironmentUrl(TALXIS.CLI.Core.Model.Connection connection) + { + if (!Uri.TryCreate(connection.EnvironmentUrl, UriKind.Absolute, out var envUri)) + throw new InvalidOperationException( + $"Dataverse connection '{connection.Id}' EnvironmentUrl '{connection.EnvironmentUrl}' is not a valid absolute URI."); + return envUri; + } + + /// Result of : the resolved profile pair + validated environment URL. + public sealed record PrimedProfile(TALXIS.CLI.Core.Model.Connection Connection, Credential Credential, Uri EnvironmentUrl); +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Runtime/DataverseConnectionFactory.cs b/src/TALXIS.CLI.Platform.Dataverse/Runtime/DataverseConnectionFactory.cs new file mode 100644 index 0000000..3280e8a --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Runtime/DataverseConnectionFactory.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerPlatform.Dataverse.Client; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.Dataverse; + +namespace TALXIS.CLI.Platform.Dataverse.Runtime; + +/// +/// Default . Delegates token +/// acquisition to and wires the +/// resulting bearer into a via its +/// token-provider callback. No connection strings, no env-var fallbacks: +/// everything flows from the resolved profile. +/// +public sealed class DataverseConnectionFactory : IDataverseConnectionFactory +{ + private readonly IDataverseAccessTokenService _tokens; + private readonly ILogger _logger; + + public DataverseConnectionFactory( + IDataverseAccessTokenService tokens, + ILogger? logger = null) + { + _tokens = tokens ?? throw new ArgumentNullException(nameof(tokens)); + _logger = logger ?? NullLogger.Instance; + } + + public Task ConnectAsync(ResolvedProfileContext context, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Connection.Provider != ProviderKind.Dataverse) + throw new InvalidOperationException( + $"Connection '{context.Connection.Id}' has provider {context.Connection.Provider}, expected {ProviderKind.Dataverse}."); + if (string.IsNullOrWhiteSpace(context.Connection.EnvironmentUrl)) + throw new InvalidOperationException( + $"Dataverse connection '{context.Connection.Id}' is missing EnvironmentUrl."); + if (!Uri.TryCreate(context.Connection.EnvironmentUrl, UriKind.Absolute, out var envUri)) + throw new InvalidOperationException( + $"Dataverse connection '{context.Connection.Id}' EnvironmentUrl '{context.Connection.EnvironmentUrl}' is not a valid absolute URI."); + + _logger.LogDebug( + "Connecting to Dataverse env '{EnvUrl}' via profile '{Profile}' (credential kind={Kind}, source={Source}).", + envUri, context.Profile?.Id ?? "(ephemeral)", context.Credential.Kind, context.Source); + + // ServiceClient invokes the token provider on every request that needs a bearer; + // the service caches internally based on freshness, and the underlying + // IDataverseAccessTokenService delegates to MSAL which provides its own caching. + var conn = context.Connection; + var cred = context.Credential; + var client = new ServiceClient( + envUri, + async resource => + { + // Honor the resource requested by the Dataverse SDK because it may + // canonicalize or redirect the organization URL before asking for a token. + if (!string.IsNullOrWhiteSpace(resource) && + Uri.TryCreate(resource, UriKind.Absolute, out var resourceUri)) + { + return await _tokens.AcquireForResourceAsync(conn, cred, resourceUri, ct).ConfigureAwait(false); + } + + return await _tokens.AcquireAsync(conn, cred, ct).ConfigureAwait(false); + }, + useUniqueInstance: true, + logger: null); + + if (!client.IsReady) + { + var error = client.LastError; + client.Dispose(); + throw new InvalidOperationException( + $"Failed to establish Dataverse connection to '{envUri}' for profile '{context.Profile?.Id ?? "(ephemeral)"}': {error}"); + } + + return Task.FromResult(DataverseConnection.FromServiceClient(client)); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Runtime/IDataverseAccessTokenService.cs b/src/TALXIS.CLI.Platform.Dataverse/Runtime/IDataverseAccessTokenService.cs new file mode 100644 index 0000000..3bfce02 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Runtime/IDataverseAccessTokenService.cs @@ -0,0 +1,60 @@ +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Platform.Dataverse.Runtime; + +/// +/// Acquires Microsoft Entra access tokens for Dataverse given a resolved +/// (Connection, Credential) pair. This is the single place MSAL is driven +/// for the refactored Dataverse commands and the live WhoAmI check. +/// +/// +/// Token-cache behaviour depends on the credential kind: +/// +/// — silent via the +/// shared MSAL user cache; throws a precise error if the user has to +/// re-run txc config auth login. +/// and +/// — confidential client, +/// MSAL's in-memory app-token cache (tokens are short-lived and +/// service-principal secrets rotate often, so we don't persist them). +/// — fresh +/// assertion per call via FederatedAssertionCallbacks.AutoSelect; +/// MSAL caches the resulting access token in-memory for its lifetime. +/// +/// +/// Kinds not yet supported (DeviceCode, ManagedIdentity, AzureCli, Pat) +/// throw with a remedy message. +/// +/// +public interface IDataverseAccessTokenService +{ + /// + /// Acquires a bearer token scoped to the Dataverse environment URL on + /// using the given + /// . + /// + Task AcquireAsync(TALXIS.CLI.Core.Model.Connection connection, Credential credential, CancellationToken ct); + + /// + /// Acquires a bearer token scoped to . + /// Required for token-provider callbacks passed to the Xrm Tooling + /// ServiceClient, which may request tokens for the SDK-canonicalized + /// org URL rather than the pre-login Connection.EnvironmentUrl. + /// + /// + /// The resolved Dataverse connection. Used for authority selection and + /// public-client identity lookup (cached account match). + /// + /// The resolved credential. + /// + /// The audience URL the caller needs a token for (e.g. + /// https://contoso.crm.dynamics.com). Scope is built via + /// from this URI. + /// + /// Cancellation token. + Task AcquireForResourceAsync( + TALXIS.CLI.Core.Model.Connection connection, + Credential credential, + Uri resourceUri, + CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Runtime/IDataverseConnectionFactory.cs b/src/TALXIS.CLI.Platform.Dataverse/Runtime/IDataverseConnectionFactory.cs new file mode 100644 index 0000000..7c45856 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Runtime/IDataverseConnectionFactory.cs @@ -0,0 +1,20 @@ +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.Dataverse; + +namespace TALXIS.CLI.Platform.Dataverse.Runtime; + +/// +/// Builds a ready-to-use from a resolved +/// (Profile, Connection, Credential) triple. Every leaf Dataverse command +/// goes through this one abstraction. +/// +public interface IDataverseConnectionFactory +{ + /// + /// Connects to the Dataverse environment described by + /// . Caller owns the returned + /// (dispose to release the + /// ). + /// + Task ConnectAsync(ResolvedProfileContext context, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Scopes/DataverseScope.cs b/src/TALXIS.CLI.Platform.Dataverse/Scopes/DataverseScope.cs new file mode 100644 index 0000000..e5a7193 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Scopes/DataverseScope.cs @@ -0,0 +1,21 @@ +namespace TALXIS.CLI.Platform.Dataverse.Scopes; + +/// +/// Builds the Dataverse MSAL scope string. +/// +/// +/// Dataverse requires a //.default suffix (double-slash, one slash +/// more than most Azure services). This matches pac CLI's +/// useDoubleSlashScopeSeparator = true default and is mandatory for +/// CRM audience matching — see temp/pac-auth-research.md. +/// +public static class DataverseScope +{ + public const string DefaultSuffix = "//.default"; + + public static string BuildDefault(Uri environmentUrl) + { + ArgumentNullException.ThrowIfNull(environmentUrl); + return environmentUrl.GetLeftPart(UriPartial.Authority) + DefaultSuffix; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseDataPackageService.cs b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseDataPackageService.cs new file mode 100644 index 0000000..a3a746d --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseDataPackageService.cs @@ -0,0 +1,39 @@ +using Microsoft.Identity.Client; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Platforms; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Platform.Xrm; + +namespace TALXIS.CLI.Platform.Dataverse.Services; + +internal sealed class DataverseDataPackageService : IDataPackageService +{ + public async Task ImportAsync( + string? profileName, + string dataPackagePath, + int connectionCount, + bool verbose, + CancellationToken ct) + { + try + { + await DataverseCommandBridge.PrimeTokenAsync(profileName, ct).ConfigureAwait(false); + } + catch (MsalUiRequiredException) + { + return new DataPackageImportResult(false, null, InteractiveAuthRequired: true); + } + catch (Exception ex) when (ex is ConfigurationResolutionException or InvalidOperationException or NotSupportedException) + { + return new DataPackageImportResult(false, ex.Message, InteractiveAuthRequired: false); + } + + var request = new CmtImportRequest(Path.GetFullPath(dataPackagePath), connectionCount, verbose); + CmtImportResult result = await LegacyAssemblyHostSubprocess + .RunCmtImportAsync(request, profileName ?? string.Empty, ct) + .ConfigureAwait(false); + + return new DataPackageImportResult(result.Succeeded, result.ErrorMessage, InteractiveAuthRequired: false); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseDeploymentDetailService.cs b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseDeploymentDetailService.cs new file mode 100644 index 0000000..ac5316e --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseDeploymentDetailService.cs @@ -0,0 +1,296 @@ +using Microsoft.Extensions.Logging; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Platforms; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Platform.Dataverse; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Platform.Dataverse.Services; + +internal sealed class DataverseDeploymentDetailService : IDeploymentDetailService +{ + private static readonly TimeSpan CorrelationTailBuffer = TimeSpan.FromSeconds(30); + + public async Task GetByPackageRunIdAsync(string? profileName, Guid id, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var logger = TxcLoggerFactory.CreateLogger(nameof(DataverseDeploymentDetailService)); + var pkg = await new PackageHistoryReader(conn.Client, logger).GetByIdAsync(id).ConfigureAwait(false); + return pkg is null ? null : await BuildPackageAsync(conn.Client, pkg, logger).ConfigureAwait(false); + } + + public async Task GetBySolutionRunIdAsync(string? profileName, Guid id, bool includeFull, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var logger = TxcLoggerFactory.CreateLogger(nameof(DataverseDeploymentDetailService)); + var sol = await new SolutionHistoryReader(conn.Client, logger).GetByIdAsync(id).ConfigureAwait(false); + return sol is null ? null : await BuildSolutionAsync(conn.Client, sol, includeFull, logger).ConfigureAwait(false); + } + + public async Task GetByAsyncOperationIdAsync(string? profileName, Guid id, bool includeFull, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var logger = TxcLoggerFactory.CreateLogger(nameof(DataverseDeploymentDetailService)); + var solReader = new SolutionHistoryReader(conn.Client, logger); + var sol = await solReader.GetByActivityIdAsync(id).ConfigureAwait(false); + if (sol is not null) + { + return await BuildSolutionAsync(conn.Client, sol, includeFull, logger).ConfigureAwait(false); + } + return await TryBuildAsyncOperationAsync(conn.Client, id, includeFull, logger).ConfigureAwait(false); + } + + public async Task GetLatestByPackageNameAsync(string? profileName, string packageName, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var logger = TxcLoggerFactory.CreateLogger(nameof(DataverseDeploymentDetailService)); + var pkg = await new PackageHistoryReader(conn.Client, logger).GetLatestAsync(packageName).ConfigureAwait(false); + return pkg is null ? null : await BuildPackageAsync(conn.Client, pkg, logger).ConfigureAwait(false); + } + + public async Task GetLatestBySolutionNameAsync(string? profileName, string solutionName, bool includeFull, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var logger = TxcLoggerFactory.CreateLogger(nameof(DataverseDeploymentDetailService)); + var sol = await new SolutionHistoryReader(conn.Client, logger).GetLatestByNameAsync(solutionName).ConfigureAwait(false); + return sol is null ? null : await BuildSolutionAsync(conn.Client, sol, includeFull, logger).ConfigureAwait(false); + } + + public async Task GetLatestAsync(string? profileName, bool includeFull, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var logger = TxcLoggerFactory.CreateLogger(nameof(DataverseDeploymentDetailService)); + var pkgReader = new PackageHistoryReader(conn.Client, logger); + var solReader = new SolutionHistoryReader(conn.Client, logger); + var pkgTask = pkgReader.GetRecentAsync(1); + var solTask = solReader.GetRecentAsync(1); + await Task.WhenAll(pkgTask, solTask).ConfigureAwait(false); + var pkg = (await pkgTask.ConfigureAwait(false)).FirstOrDefault(); + var sol = (await solTask.ConfigureAwait(false)).FirstOrDefault(); + if (pkg is null && sol is null) return null; + + var pkgTime = pkg?.StartedAtUtc ?? DateTime.MinValue; + var solTime = sol?.StartedAtUtc ?? DateTime.MinValue; + if (pkg is not null && (sol is null || pkgTime >= solTime)) + { + return await BuildPackageAsync(conn.Client, pkg, logger).ConfigureAwait(false); + } + return await BuildSolutionAsync(conn.Client, sol!, includeFull, logger).ConfigureAwait(false); + } + + private static async Task BuildPackageAsync( + ServiceClient client, + PackageHistoryRecord record, + Microsoft.Extensions.Logging.ILogger logger) + { + var solReader = new SolutionHistoryReader(client, logger); + IReadOnlyList correlated = Array.Empty(); + if (record.StartedAtUtc is { } startedAt) + { + try + { + if (record.CorrelationId is { } corrId && corrId != Guid.Empty) + { + correlated = await solReader.GetByCorrelationIdAsync(corrId).ConfigureAwait(false); + } + if (correlated.Count == 0) + { + var windowEnd = (record.CompletedAtUtc ?? startedAt) + CorrelationTailBuffer; + correlated = await solReader.GetInTimeWindowAsync(startedAt, windowEnd).ConfigureAwait(false); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to enrich package run with solution history."); + } + } + + var findings = DeploymentFindingsAnalyzer.Analyze(new DeploymentFindingsInput + { + ImportJobData = null, + Primary = null, + Solutions = correlated, + IsPackageMode = true, + IncludeSolutions = true, + PackageStatus = record.Status, + PackageStartedAtUtc = record.StartedAtUtc, + }); + + return new DeploymentDetailResult( + Kind: DeploymentRunKind.Package, + Package: record, + CorrelatedSolutions: correlated, + Solution: null, + ParentPackage: null, + ImportJobId: null, + FormattedImportLog: null, + AsyncOperation: null, + Findings: findings); + } + + private static async Task BuildSolutionAsync( + ServiceClient client, + SolutionHistoryRecord record, + bool includeFull, + Microsoft.Extensions.Logging.ILogger logger) + { + PackageHistoryRecord? parentPackage = null; + if (record.StartedAtUtc is { } startedAt) + { + try + { + var pkgReader = new PackageHistoryReader(client, logger); + var nearby = await pkgReader.GetRecentAsync(50, startedAt - CorrelationTailBuffer, problemsOnly: false).ConfigureAwait(false); + parentPackage = nearby.FirstOrDefault(p => + p.StartedAtUtc is { } ps + && ps <= startedAt + && ((p.CompletedAtUtc ?? ps) + CorrelationTailBuffer) >= startedAt); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to locate parent package for solution history row."); + } + } + + string? formattedLog = null; + ImportJobRecord? importJobMatch = null; + if (includeFull) + { + try + { + var importReader = new ImportJobReader(client, logger); + if (record.StartedAtUtc is { } startedAt2) + { + var windowStart = startedAt2; + var windowEnd = (record.CompletedAtUtc ?? startedAt2) + CorrelationTailBuffer; + var jobs = await importReader.GetInTimeWindowAsync(windowStart, windowEnd).ConfigureAwait(false); + importJobMatch = jobs.FirstOrDefault(j => + record.SolutionName is not null + && string.Equals(j.SolutionName, record.SolutionName, StringComparison.OrdinalIgnoreCase)); + if (importJobMatch is not null) + { + formattedLog = await importReader.GetFormattedResultsAsync(importJobMatch.Id).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to retrieve formatted import log for solution history row."); + } + } + + var findings = DeploymentFindingsAnalyzer.Analyze(new DeploymentFindingsInput + { + ImportJobData = importJobMatch?.Data, + Primary = record, + Solutions = new[] { record }, + IsPackageMode = false, + IncludeSolutions = false, + }); + + return new DeploymentDetailResult( + Kind: DeploymentRunKind.Solution, + Package: null, + CorrelatedSolutions: Array.Empty(), + Solution: record, + ParentPackage: parentPackage, + ImportJobId: importJobMatch?.Id, + FormattedImportLog: formattedLog, + AsyncOperation: null, + Findings: findings); + } + + private static async Task TryBuildAsyncOperationAsync( + ServiceClient client, + Guid asyncOpId, + bool includeFull, + Microsoft.Extensions.Logging.ILogger logger) + { + Entity entity; + try + { + entity = await client.RetrieveAsync( + DataverseSchema.AsyncOperation.EntityName, + asyncOpId, + new ColumnSet("statecode", "statuscode", "message", "friendlymessage", "createdon", "completedon"), + default).ConfigureAwait(false); + } + catch (Exception ex) when (IsNotFoundError(ex)) + { + return null; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to retrieve asyncoperation {Id}.", asyncOpId); + return null; + } + + int statecode = entity.GetAttributeValue("statecode")?.Value ?? 0; + int statuscode = entity.GetAttributeValue("statuscode")?.Value ?? 0; + + if (statecode != 3) + { + string stateLabel = statecode switch + { + 0 => "Ready", + 1 => "Suspended", + 2 => "In Progress", + _ => $"State {statecode}", + }; + return new DeploymentDetailResult( + Kind: DeploymentRunKind.AsyncOperationInProgress, + Package: null, + CorrelatedSolutions: Array.Empty(), + Solution: null, + ParentPackage: null, + ImportJobId: null, + FormattedImportLog: null, + AsyncOperation: new AsyncOperationSummary(asyncOpId, stateLabel, statecode, statuscode, Completed: false, Succeeded: false, Message: null), + Findings: Array.Empty()); + } + + bool succeeded = statuscode == 30; + DateTime? completedOn = entity.Contains("completedon") ? entity.GetAttributeValue("completedon") : null; + DateTime? createdOn = entity.Contains("createdon") ? entity.GetAttributeValue("createdon") : null; + + var historyReader = new SolutionHistoryReader(client, logger); + DateTime pivot = completedOn ?? createdOn ?? DateTime.UtcNow; + SolutionHistoryRecord? sol = null; + bool veryRecent = (DateTime.UtcNow - pivot).TotalSeconds < 60; + int attempts = veryRecent ? 3 : 1; + for (int i = 0; i < attempts; i++) + { + if (i > 0) await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + sol = await historyReader.GetByActivityIdAsync(asyncOpId, nearUtc: pivot).ConfigureAwait(false); + if (sol is not null) break; + } + if (sol is not null) + { + return await BuildSolutionAsync(client, sol, includeFull, logger).ConfigureAwait(false); + } + + string? message = entity.GetAttributeValue("friendlymessage") + ?? entity.GetAttributeValue("message"); + return new DeploymentDetailResult( + Kind: DeploymentRunKind.AsyncOperationCompleted, + Package: null, + CorrelatedSolutions: Array.Empty(), + Solution: null, + ParentPackage: null, + ImportJobId: null, + FormattedImportLog: null, + AsyncOperation: new AsyncOperationSummary(asyncOpId, "Completed", statecode, statuscode, Completed: true, Succeeded: succeeded, Message: message), + Findings: Array.Empty()); + } + + private static bool IsNotFoundError(Exception ex) + { + if (ex.Message.Contains("0x80040217", StringComparison.OrdinalIgnoreCase)) return true; + if (ex.Message.Contains("Does Not Exist", StringComparison.OrdinalIgnoreCase)) return true; + if (ex.Message.Contains("ObjectDoesNotExist", StringComparison.OrdinalIgnoreCase)) return true; + return false; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseDeploymentHistoryService.cs b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseDeploymentHistoryService.cs new file mode 100644 index 0000000..4b702df --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseDeploymentHistoryService.cs @@ -0,0 +1,34 @@ +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Platforms; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Platform.Dataverse.Services; + +internal sealed class DataverseDeploymentHistoryService : IDeploymentHistoryService +{ + public async Task GetRecentAsync( + string? profileName, + bool includePackages, + bool includeSolutions, + int maxCount, + DateTime? sinceUtc, + bool problemsOnly, + CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var logger = TxcLoggerFactory.CreateLogger(nameof(DataverseDeploymentHistoryService)); + + var pkgTask = includePackages + ? new PackageHistoryReader(conn.Client, logger).GetRecentAsync(maxCount, sinceUtc, problemsOnly) + : Task.FromResult>(Array.Empty()); + var solTask = includeSolutions + ? new SolutionHistoryReader(conn.Client, logger).GetRecentAsync(maxCount, sinceUtc, problemsOnly) + : Task.FromResult>(Array.Empty()); + + await Task.WhenAll(pkgTask, solTask).ConfigureAwait(false); + return new DeploymentHistorySnapshot( + await pkgTask.ConfigureAwait(false), + await solTask.ConfigureAwait(false)); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Services/DataversePackageImportService.cs b/src/TALXIS.CLI.Platform.Dataverse/Services/DataversePackageImportService.cs new file mode 100644 index 0000000..2578842 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Services/DataversePackageImportService.cs @@ -0,0 +1,57 @@ +using Microsoft.Identity.Client; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Platforms; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Platform.Xrm; + +namespace TALXIS.CLI.Platform.Dataverse.Services; + +internal sealed class DataversePackageImportService : IPackageImportService +{ + public async Task ImportAsync(PackageImportRequest request, CancellationToken ct) + { + try + { + try + { + await DataverseCommandBridge.PrimeTokenAsync(request.ProfileName, ct).ConfigureAwait(false); + } + catch (MsalUiRequiredException) + { + return new PackageImportResult( + Succeeded: false, + ErrorMessage: null, + LogFilePath: null, + CmtLogFilePath: null, + InteractiveAuthRequired: true); + } + + var deploy = await LegacyAssemblyHostSubprocess.RunPackageDeployerAsync(new PackageDeployerRequest( + request.PackagePath, + ProfileId: request.ProfileName ?? string.Empty, + ConfigDirectory: null, + request.Settings, + request.LogFile, + request.LogConsole, + request.Verbose, + TemporaryArtifactsDirectory: string.Empty, + ParentProcessId: System.Environment.ProcessId, + NuGetPackageName: request.NuGetPackageName, + NuGetPackageVersion: request.NuGetPackageVersion)).ConfigureAwait(false); + + return new PackageImportResult( + Succeeded: deploy.Succeeded, + ErrorMessage: deploy.ErrorMessage, + LogFilePath: deploy.LogFilePath, + CmtLogFilePath: deploy.CmtLogFilePath, + InteractiveAuthRequired: false); + } + finally + { + if (!string.IsNullOrWhiteSpace(request.TempWorkingDirectory)) + { + LegacyAssemblyHostSubprocess.TryDeleteDirectory(request.TempWorkingDirectory); + } + } + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Services/DataversePackageUninstallService.cs b/src/TALXIS.CLI.Platform.Dataverse/Services/DataversePackageUninstallService.cs new file mode 100644 index 0000000..f614c4f --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Services/DataversePackageUninstallService.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Platforms; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Platform.Dataverse.Services; + +internal sealed class DataversePackageUninstallService : IPackageUninstallService +{ + public async Task UninstallAsync(PackageUninstallRequest request, CancellationToken ct) + { + var logger = TxcLoggerFactory.CreateLogger(nameof(DataversePackageUninstallService)); + + var reader = new PackageImportConfigReader(); + var importOrder = await reader.ReadSolutionUniqueNamesInImportOrderAsync( + request.PackageSource, + request.PackageVersion, + request.OutputDirectory) + .ConfigureAwait(false); + + var solutionNames = BuildReverseUninstallOrder(importOrder); + var packageDisplayName = InferPackageDisplayNameFromSource(request.PackageSource); + + if (solutionNames.Count == 0) + { + return new PackageUninstallResult(packageDisplayName, solutionNames, Array.Empty()); + } + + using var conn = await DataverseCommandBridge.ConnectAsync(request.ProfileName, ct).ConfigureAwait(false); + var client = conn.Client; + var uninstaller = new SolutionUninstaller(client, logger); + + var historyWriter = new PackageHistoryWriter(client, logger); + var statusCodes = await historyWriter.ResolveStatusCodesAsync().ConfigureAwait(false); + var created = await historyWriter.TryCreateUninstallRunAsync( + uniqueName: packageDisplayName, + executionName: $"txc uninstall {request.PackageSource}", + statusCode: statusCodes.InProcessStatus, + message: $"Package uninstall started. {solutionNames.Count} solution(s) in reverse order.") + .ConfigureAwait(false); + + var outcomes = new List(solutionNames.Count); + for (int i = 0; i < solutionNames.Count; i++) + { + var name = solutionNames[i]; + logger.LogInformation("[{Current}/{Total}] Uninstalling solution {SolutionName}...", i + 1, solutionNames.Count, name); + var outcome = await uninstaller.UninstallByUniqueNameAsync(name).ConfigureAwait(false); + outcomes.Add(outcome); + + if (outcome.Status == SolutionUninstallStatus.Success) + { + logger.LogInformation("[{Current}/{Total}] {SolutionName}: {Status}", i + 1, solutionNames.Count, outcome.SolutionName, outcome.Status); + } + else + { + logger.LogWarning("[{Current}/{Total}] {SolutionName}: {Status} ({Message})", i + 1, solutionNames.Count, outcome.SolutionName, outcome.Status, outcome.Message); + } + } + + if (created?.Id is { } id) + { + bool allSuccess = outcomes.All(o => o.Status == SolutionUninstallStatus.Success); + await historyWriter.TryUpdateStatusAsync( + id, + allSuccess ? statusCodes.SuccessState : statusCodes.FailedState, + allSuccess ? statusCodes.SuccessStatus : statusCodes.FailedStatus, + allSuccess + ? $"Package uninstall completed. {outcomes.Count} solution(s) uninstalled." + : $"Package uninstall completed with failures. {outcomes.Count(o => o.Status == SolutionUninstallStatus.Success)}/{outcomes.Count} succeeded.") + .ConfigureAwait(false); + } + + return new PackageUninstallResult(packageDisplayName, solutionNames, outcomes); + } + + private static IReadOnlyList BuildReverseUninstallOrder(IReadOnlyList importOrder) + { + var ordered = importOrder + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Select(n => n.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + ordered.Reverse(); + return ordered; + } + + private static string InferPackageDisplayNameFromSource(string packageSource) + { + if (string.IsNullOrWhiteSpace(packageSource)) return "(unknown)"; + + if (Directory.Exists(packageSource) || File.Exists(packageSource)) + { + var name = Path.GetFileName(packageSource.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (name.EndsWith(".pdpkg.zip", StringComparison.OrdinalIgnoreCase)) return name[..^".pdpkg.zip".Length]; + if (name.EndsWith(".pdpkg", StringComparison.OrdinalIgnoreCase)) return name[..^".pdpkg".Length]; + return string.IsNullOrWhiteSpace(name) ? "(unknown)" : name; + } + + return packageSource.Trim(); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseSolutionImportService.cs b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseSolutionImportService.cs new file mode 100644 index 0000000..887fb3d --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseSolutionImportService.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Platforms; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Platform.Dataverse.Services; + +internal sealed class DataverseSolutionImportService : ISolutionImportService +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(DataverseSolutionImportService)); + + public async Task ImportAsync( + string? profileName, + string solutionZipPath, + SolutionImportOptions options, + CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var importer = new SolutionImporter(conn.Client, _logger); + return await importer.ImportAsync(solutionZipPath, options).ConfigureAwait(false); + } +} diff --git a/src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionInventoryReader.cs b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseSolutionInventoryService.cs similarity index 57% rename from src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionInventoryReader.cs rename to src/TALXIS.CLI.Platform.Dataverse/Services/DataverseSolutionInventoryService.cs index 7b415b4..1dd1c24 100644 --- a/src/TALXIS.CLI.Environment/Platforms/Dataverse/SolutionInventoryReader.cs +++ b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseSolutionInventoryService.cs @@ -1,24 +1,36 @@ using Microsoft.PowerPlatform.Dataverse.Client; -using TALXIS.CLI.Dataverse; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Platform.Dataverse; -namespace TALXIS.CLI.Environment.Platforms.Dataverse; +namespace TALXIS.CLI.Platform.Dataverse.Services; /// -/// Lightweight inventory row for an installed Dataverse solution. +/// Dataverse implementation of . +/// Combines profile/connection resolution with the raw solution table +/// query so feature commands only see the abstraction. /// -public sealed record InstalledSolutionRecord( - Guid Id, - string UniqueName, - string? FriendlyName, - string? Version, - bool Managed); +internal sealed class DataverseSolutionInventoryService : ISolutionInventoryService +{ + public async Task> ListAsync( + string? profileName, + bool? managedFilter, + CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + return await SolutionInventoryReader.ListAsync(conn.Client, managedFilter, ct: ct).ConfigureAwait(false); + } +} /// -/// Reads the currently installed solution inventory from the Dataverse solution table. +/// Reads the currently installed solution inventory from the Dataverse +/// solution table. Kept as a standalone helper so existing unit tests +/// (which mock directly) can keep +/// exercising the query without going through the service-locator path. /// -public sealed class SolutionInventoryReader +internal static class SolutionInventoryReader { private const string EntityName = DataverseSchema.Solution.EntityName; private static readonly ColumnSet Columns = new( @@ -28,21 +40,13 @@ public sealed class SolutionInventoryReader "version", "ismanaged"); - private readonly IOrganizationServiceAsync2 _service; - - public SolutionInventoryReader(IOrganizationServiceAsync2 service) - { - _service = service ?? throw new ArgumentNullException(nameof(service)); - } - - /// - /// Lists installed solutions, optionally filtering by managed/unmanaged type. - /// - public async Task> ListAsync( + public static async Task> ListAsync( + IOrganizationServiceAsync2 service, bool? managedOnly = null, int maxRows = 5000, CancellationToken ct = default) { + if (service is null) throw new ArgumentNullException(nameof(service)); if (maxRows <= 0) throw new ArgumentOutOfRangeException(nameof(maxRows), "maxRows must be > 0."); var query = new QueryExpression(EntityName) @@ -59,7 +63,7 @@ public async Task> ListAsync( query.Criteria.AddCondition("ismanaged", ConditionOperator.Equal, managed); } - var response = await _service.RetrieveMultipleAsync(query, ct).ConfigureAwait(false); + var response = await service.RetrieveMultipleAsync(query, ct).ConfigureAwait(false); return response.Entities .Select(ToRecord) .Where(r => !string.IsNullOrWhiteSpace(r.UniqueName)) diff --git a/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseSolutionUninstallService.cs b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseSolutionUninstallService.cs new file mode 100644 index 0000000..913ba4c --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/Services/DataverseSolutionUninstallService.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Platforms; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Platform.Dataverse.Services; + +internal sealed class DataverseSolutionUninstallService : ISolutionUninstallService +{ + private readonly ILogger _logger = TxcLoggerFactory.CreateLogger(nameof(DataverseSolutionUninstallService)); + + public async Task UninstallByUniqueNameAsync( + string? profileName, + string uniqueName, + CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var uninstaller = new SolutionUninstaller(conn.Client, _logger); + return await uninstaller.UninstallByUniqueNameAsync(uniqueName).ConfigureAwait(false); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse/TALXIS.CLI.Platform.Dataverse.csproj b/src/TALXIS.CLI.Platform.Dataverse/TALXIS.CLI.Platform.Dataverse.csproj new file mode 100644 index 0000000..58b19fe --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse/TALXIS.CLI.Platform.Dataverse.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + TALXIS.CLI.Platform.Dataverse + + + + + + + + + + + + + + + + + + + + + diff --git a/src/TALXIS.CLI.Platform.Xrm/CmtImportRequest.cs b/src/TALXIS.CLI.Platform.Xrm/CmtImportRequest.cs new file mode 100644 index 0000000..3a08d41 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Xrm/CmtImportRequest.cs @@ -0,0 +1,18 @@ +namespace TALXIS.CLI.Platform.Xrm; + +/// +/// Identity-neutral request record for . The +/// connection string is passed as a separate parameter on +/// so that the request object — which +/// may be logged, serialized, or stored in flight — never carries secrets. +/// This mirrors . +/// +public sealed record CmtImportRequest( + /// Path to the CMT data package (.zip file or extracted folder containing data.xml and data_schema.xml). + string DataPath, + + /// Number of parallel import connections. Defaults to 1. + int ConnectionCount, + + /// Enable verbose CMT trace output. + bool Verbose); diff --git a/src/TALXIS.CLI.XrmTools/CmtImportResult.cs b/src/TALXIS.CLI.Platform.Xrm/CmtImportResult.cs similarity index 81% rename from src/TALXIS.CLI.XrmTools/CmtImportResult.cs rename to src/TALXIS.CLI.Platform.Xrm/CmtImportResult.cs index ae24db3..4d4e49e 100644 --- a/src/TALXIS.CLI.XrmTools/CmtImportResult.cs +++ b/src/TALXIS.CLI.Platform.Xrm/CmtImportResult.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.XrmTools; +namespace TALXIS.CLI.Platform.Xrm; /// /// Result of a standalone CMT data import. diff --git a/src/TALXIS.CLI.XrmTools/CmtImportRunner.cs b/src/TALXIS.CLI.Platform.Xrm/CmtImportRunner.cs similarity index 87% rename from src/TALXIS.CLI.XrmTools/CmtImportRunner.cs rename to src/TALXIS.CLI.Platform.Xrm/CmtImportRunner.cs index 40fc76c..3dc2423 100644 --- a/src/TALXIS.CLI.XrmTools/CmtImportRunner.cs +++ b/src/TALXIS.CLI.Platform.Xrm/CmtImportRunner.cs @@ -8,7 +8,7 @@ using Microsoft.Xrm.Tooling.Dmt.ImportProcessor.DataInteraction; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.XrmTools; +namespace TALXIS.CLI.Platform.Xrm; /// /// Standalone CMT data import runner — uses ImportCrmDataHandler @@ -58,11 +58,43 @@ public CmtImportRunner() /// Import data /// /// - public async Task RunAsync(CmtImportRequest request) + public Task RunAsync(CmtImportRequest request, string connectionString, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + return RunInternalAsync(request, () => new CrmServiceClient(connectionString), cancellationToken); + } + + /// + /// Token-provider overload matching + /// . + /// Primary and clone instances are built + /// via the capture-free (Uri, Func<string, Task<string>>, ...) + /// constructor, so no static auth state is involved. + /// + public Task RunAsync( + CmtImportRequest request, + Uri environmentUrl, + Func> tokenProvider, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(environmentUrl); + ArgumentNullException.ThrowIfNull(tokenProvider); + if (!environmentUrl.IsAbsoluteUri) + throw new ArgumentException($"Environment URL '{environmentUrl}' must be absolute.", nameof(environmentUrl)); + + return RunInternalAsync( + request, + () => new CrmServiceClient(environmentUrl, tokenProvider, useUniqueInstance: true), + cancellationToken); + } - bool isDirectory = Directory.Exists(request.DataPath); + private async Task RunInternalAsync( + CmtImportRequest request, + Func clientFactory, + CancellationToken cancellationToken) + { bool isDirectory = Directory.Exists(request.DataPath); bool isFile = !isDirectory && File.Exists(request.DataPath); if (!isDirectory && !isFile) @@ -79,7 +111,6 @@ public async Task RunAsync(CmtImportRequest request) // Track whether we created a temp folder that should be cleaned up. bool ownsWorkingFolder = false; CrmServiceClient? crmServiceClient = null; - DataverseInteractiveAuthHook? authHook = null; try { @@ -117,22 +148,7 @@ public async Task RunAsync(CmtImportRequest request) RegisterExtractedDirectoryForProbing(workingFolder); // 2. Connect to Dataverse. - if (!string.IsNullOrWhiteSpace(request.ConnectionString)) - { - crmServiceClient = new CrmServiceClient(request.ConnectionString); - } - else - { - if (string.IsNullOrWhiteSpace(request.EnvironmentUrl) || - !Uri.TryCreate(request.EnvironmentUrl, UriKind.Absolute, out Uri? environmentUri)) - { - return new CmtImportResult(false, "A valid Dataverse environment URL or connection string is required."); - } - - authHook = new DataverseInteractiveAuthHook(environmentUri, request.DeviceCode, request.Verbose); - CrmServiceClient.AuthOverrideHook = authHook; - crmServiceClient = new CrmServiceClient(environmentUri, useUniqueInstance: true); - } + crmServiceClient = clientFactory(); if (!crmServiceClient.IsReady) { @@ -169,18 +185,9 @@ public async Task RunAsync(CmtImportRequest request) try { // Create additional CrmServiceClient instances using the - // same auth approach — either connection string or auth hook. - CrmServiceClient clone; - if (!string.IsNullOrWhiteSpace(request.ConnectionString)) - { - clone = new CrmServiceClient(request.ConnectionString); - } - else - { - clone = new CrmServiceClient( - new Uri(request.EnvironmentUrl!), - useUniqueInstance: true); - } + // same auth as the primary client (connection string or + // token-provider callback, via the shared factory). + CrmServiceClient clone = clientFactory(); if (clone.IsReady) { @@ -242,7 +249,6 @@ public async Task RunAsync(CmtImportRequest request) } finally { - authHook?.Dispose(); crmServiceClient?.Dispose(); AppDomain.CurrentDomain.AssemblyResolve -= OnResolveAssembly; diff --git a/src/TALXIS.CLI.XrmTools/LegacyAssemblyRuntime.cs b/src/TALXIS.CLI.Platform.Xrm/LegacyAssemblyRuntime.cs similarity index 99% rename from src/TALXIS.CLI.XrmTools/LegacyAssemblyRuntime.cs rename to src/TALXIS.CLI.Platform.Xrm/LegacyAssemblyRuntime.cs index f42732b..80cd79b 100644 --- a/src/TALXIS.CLI.XrmTools/LegacyAssemblyRuntime.cs +++ b/src/TALXIS.CLI.Platform.Xrm/LegacyAssemblyRuntime.cs @@ -2,7 +2,7 @@ using System.Runtime.Loader; using Mono.Cecil; -namespace TALXIS.CLI.XrmTools; +namespace TALXIS.CLI.Platform.Xrm; /// /// Shared runtime infrastructure for hosting legacy .NET Framework assemblies diff --git a/src/TALXIS.CLI.XrmTools/PackageDeployerRequest.cs b/src/TALXIS.CLI.Platform.Xrm/PackageDeployerRequest.cs similarity index 58% rename from src/TALXIS.CLI.XrmTools/PackageDeployerRequest.cs rename to src/TALXIS.CLI.Platform.Xrm/PackageDeployerRequest.cs index a573f24..ec1cbaa 100644 --- a/src/TALXIS.CLI.XrmTools/PackageDeployerRequest.cs +++ b/src/TALXIS.CLI.Platform.Xrm/PackageDeployerRequest.cs @@ -1,10 +1,17 @@ -namespace TALXIS.CLI.XrmTools; +namespace TALXIS.CLI.Platform.Xrm; +/// +/// Transport contract between the txc parent process and the +/// __txc_internal_package_deployer subprocess. Intentionally contains +/// NO secrets — the child re-resolves the credential from the OS vault via +/// IConfigurationResolver using (and, if set, +/// to scope the vault to a specific +/// TXC_CONFIG_DIR). +/// public sealed record PackageDeployerRequest( string PackagePath, - string? ConnectionString, - string? EnvironmentUrl, - bool DeviceCode, + string ProfileId, + string? ConfigDirectory, string? Settings, string? LogFile, bool LogConsole, diff --git a/src/TALXIS.CLI.XrmTools/PackageDeployerResult.cs b/src/TALXIS.CLI.Platform.Xrm/PackageDeployerResult.cs similarity index 84% rename from src/TALXIS.CLI.XrmTools/PackageDeployerResult.cs rename to src/TALXIS.CLI.Platform.Xrm/PackageDeployerResult.cs index e8118f6..a1b0978 100644 --- a/src/TALXIS.CLI.XrmTools/PackageDeployerResult.cs +++ b/src/TALXIS.CLI.Platform.Xrm/PackageDeployerResult.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.XrmTools; +namespace TALXIS.CLI.Platform.Xrm; public sealed record PackageDeployerResult( bool Succeeded, diff --git a/src/TALXIS.CLI.XrmTools/PackageDeployerRunner.cs b/src/TALXIS.CLI.Platform.Xrm/PackageDeployerRunner.cs similarity index 95% rename from src/TALXIS.CLI.XrmTools/PackageDeployerRunner.cs rename to src/TALXIS.CLI.Platform.Xrm/PackageDeployerRunner.cs index 2af3dfe..921d9ef 100644 --- a/src/TALXIS.CLI.XrmTools/PackageDeployerRunner.cs +++ b/src/TALXIS.CLI.Platform.Xrm/PackageDeployerRunner.cs @@ -11,7 +11,7 @@ using Microsoft.Xrm.Tooling.PackageDeployment.CrmPackageExtentionBase; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.XrmTools; +namespace TALXIS.CLI.Platform.Xrm; public sealed class PackageDeployerRunner { @@ -20,21 +20,50 @@ static PackageDeployerRunner() LegacyAssemblyRuntime.EnsureInitialized(); } - public Task RunAsync(PackageDeployerRequest request, CancellationToken cancellationToken = default) + public Task RunAsync(PackageDeployerRequest request, string connectionString, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); - return Task.Run(() => Run(request), cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + return Task.Run(() => Run(request, () => new CrmServiceClient(connectionString)), cancellationToken); } - private static PackageDeployerResult Run(PackageDeployerRequest request) + /// + /// Token-provider overload. Prefer this for every credential kind except + /// explicit connection-string scenarios: it uses the modern + /// ServiceClient(Uri, Func<string, Task<string>>, ...) + /// constructor so InteractiveBrowser, ClientCertificate, and federated + /// credentials all work without inlining secrets anywhere. + /// + public Task RunAsync( + PackageDeployerRequest request, + Uri environmentUrl, + Func> tokenProvider, + CancellationToken cancellationToken = default) { - using ManagedPackageDeployerHost host = new(request); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(environmentUrl); + ArgumentNullException.ThrowIfNull(tokenProvider); + if (!environmentUrl.IsAbsoluteUri) + throw new ArgumentException($"Environment URL '{environmentUrl}' must be absolute.", nameof(environmentUrl)); + + // A newly-constructed CrmServiceClient binds its token callback at + // construction time, so there is no static state to restore around + // the call — two concurrent deployments remain fully isolated. + return Task.Run( + () => Run(request, () => new CrmServiceClient(environmentUrl, tokenProvider, useUniqueInstance: true)), + cancellationToken); + } + + private static PackageDeployerResult Run(PackageDeployerRequest request, Func clientFactory) + { + using ManagedPackageDeployerHost host = new(request, clientFactory); return host.Run(); } private sealed class ManagedPackageDeployerHost : IDisposable { private readonly PackageDeployerRequest _request; + private readonly Func _clientFactory; private readonly ILogger _logger; private readonly Dictionary _assemblyMap; private readonly HashSet _unresolvedAssemblies = new(StringComparer.OrdinalIgnoreCase); @@ -54,9 +83,10 @@ private sealed class ManagedPackageDeployerHost : IDisposable private BaseImportCustomizations? _import; private CoreObjects? _coreObjects; - public ManagedPackageDeployerHost(PackageDeployerRequest request) + public ManagedPackageDeployerHost(PackageDeployerRequest request, Func clientFactory) { _request = request; + _clientFactory = clientFactory; _logger = TxcLoggerFactory.CreateLogger(nameof(PackageDeployerRunner)); // Instance map starts as a copy of the static map — the instance @@ -79,27 +109,9 @@ public PackageDeployerResult Run() TraceLogger traceLogger = SetupLogging(); CrmServiceClient? crmServiceClient = null; - DataverseInteractiveAuthHook? authHook = null; try { - if (!string.IsNullOrWhiteSpace(_request.ConnectionString)) - { - crmServiceClient = new CrmServiceClient(_request.ConnectionString); - } - else - { - if (!Uri.TryCreate(_request.EnvironmentUrl, UriKind.Absolute, out Uri? environmentUri)) - { - throw new InvalidOperationException("A valid Dataverse environment URL is required for interactive authentication."); - } - - authHook = new DataverseInteractiveAuthHook(environmentUri, _request.DeviceCode, _request.Verbose); - CrmServiceClient.AuthOverrideHook = authHook; - - // Use the token provider constructor — our shim delegates - // to AuthOverrideHook internally when the URI constructor is used. - crmServiceClient = new CrmServiceClient(environmentUri, useUniqueInstance: true); - } + crmServiceClient = _clientFactory(); if (!crmServiceClient.IsReady) { @@ -235,7 +247,6 @@ System.TypeLoadException or { CrmServiceClient.AuthOverrideHook = null; crmServiceClient?.Dispose(); - authHook?.Dispose(); } } diff --git a/src/TALXIS.CLI.XrmTools/TALXIS.CLI.XrmTools.csproj b/src/TALXIS.CLI.Platform.Xrm/TALXIS.CLI.Platform.Xrm.csproj similarity index 95% rename from src/TALXIS.CLI.XrmTools/TALXIS.CLI.XrmTools.csproj rename to src/TALXIS.CLI.Platform.Xrm/TALXIS.CLI.Platform.Xrm.csproj index d4f1692..dc0a7ea 100644 --- a/src/TALXIS.CLI.XrmTools/TALXIS.CLI.XrmTools.csproj +++ b/src/TALXIS.CLI.Platform.Xrm/TALXIS.CLI.Platform.Xrm.csproj @@ -15,9 +15,8 @@ - - + diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/BatchItemOrganizationRequest.cs b/src/TALXIS.CLI.Platform.XrmShim/BatchItemOrganizationRequest.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/BatchItemOrganizationRequest.cs rename to src/TALXIS.CLI.Platform.XrmShim/BatchItemOrganizationRequest.cs diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/CrmDataTypeWrapper.cs b/src/TALXIS.CLI.Platform.XrmShim/CrmDataTypeWrapper.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/CrmDataTypeWrapper.cs rename to src/TALXIS.CLI.Platform.XrmShim/CrmDataTypeWrapper.cs diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/CrmFieldType.cs b/src/TALXIS.CLI.Platform.XrmShim/CrmFieldType.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/CrmFieldType.cs rename to src/TALXIS.CLI.Platform.XrmShim/CrmFieldType.cs diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/CrmServiceClient.cs b/src/TALXIS.CLI.Platform.XrmShim/CrmServiceClient.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/CrmServiceClient.cs rename to src/TALXIS.CLI.Platform.XrmShim/CrmServiceClient.cs diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/DynamicsFileLogTraceListener.cs b/src/TALXIS.CLI.Platform.XrmShim/DynamicsFileLogTraceListener.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/DynamicsFileLogTraceListener.cs rename to src/TALXIS.CLI.Platform.XrmShim/DynamicsFileLogTraceListener.cs diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/IOverrideAuthHookWrapper.cs b/src/TALXIS.CLI.Platform.XrmShim/IOverrideAuthHookWrapper.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/IOverrideAuthHookWrapper.cs rename to src/TALXIS.CLI.Platform.XrmShim/IOverrideAuthHookWrapper.cs diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/ImportSolutionProperties.cs b/src/TALXIS.CLI.Platform.XrmShim/ImportSolutionProperties.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/ImportSolutionProperties.cs rename to src/TALXIS.CLI.Platform.XrmShim/ImportSolutionProperties.cs diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/RequestBatch.cs b/src/TALXIS.CLI.Platform.XrmShim/RequestBatch.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/RequestBatch.cs rename to src/TALXIS.CLI.Platform.XrmShim/RequestBatch.cs diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/TALXIS.CLI.XrmTools.XrmShim.csproj b/src/TALXIS.CLI.Platform.XrmShim/TALXIS.CLI.Platform.XrmShim.csproj similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/TALXIS.CLI.XrmTools.XrmShim.csproj rename to src/TALXIS.CLI.Platform.XrmShim/TALXIS.CLI.Platform.XrmShim.csproj diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/TraceControlSettings.cs b/src/TALXIS.CLI.Platform.XrmShim/TraceControlSettings.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/TraceControlSettings.cs rename to src/TALXIS.CLI.Platform.XrmShim/TraceControlSettings.cs diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/TraceSourceSetting.cs b/src/TALXIS.CLI.Platform.XrmShim/TraceSourceSetting.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/TraceSourceSetting.cs rename to src/TALXIS.CLI.Platform.XrmShim/TraceSourceSetting.cs diff --git a/src/TALXIS.CLI.XrmTools.XrmShim/TraceSourceSettingStore.cs b/src/TALXIS.CLI.Platform.XrmShim/TraceSourceSettingStore.cs similarity index 100% rename from src/TALXIS.CLI.XrmTools.XrmShim/TraceSourceSettingStore.cs rename to src/TALXIS.CLI.Platform.XrmShim/TraceSourceSettingStore.cs diff --git a/src/TALXIS.CLI.Shared/TALXIS.CLI.Shared.csproj b/src/TALXIS.CLI.Shared/TALXIS.CLI.Shared.csproj deleted file mode 100644 index b760144..0000000 --- a/src/TALXIS.CLI.Shared/TALXIS.CLI.Shared.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net10.0 - enable - enable - - - diff --git a/src/TALXIS.CLI.Workspace/ConfigCliCommand.cs b/src/TALXIS.CLI.Workspace/ConfigCliCommand.cs deleted file mode 100644 index e58293c..0000000 --- a/src/TALXIS.CLI.Workspace/ConfigCliCommand.cs +++ /dev/null @@ -1,26 +0,0 @@ -using DotMake.CommandLine; -using TALXIS.CLI.Shared; - -namespace TALXIS.CLI.Workspace; - -[CliCommand( - Description = "Configure and troubleshoot this tool" -)] -public class ConfigCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } - - [CliCommand( - Description = "Run checks to determine if this local machine is correctly set up, validate connections and settings", - Name = "validate")] - public class ConfigValidateCliCommand - { - public void Run(CliContext context) - { - OutputWriter.WriteLine("Hello"); - } - } -} diff --git a/src/TALXIS.CLI.XrmTools/CmtImportRequest.cs b/src/TALXIS.CLI.XrmTools/CmtImportRequest.cs deleted file mode 100644 index 72932bd..0000000 --- a/src/TALXIS.CLI.XrmTools/CmtImportRequest.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace TALXIS.CLI.XrmTools; - -/// -/// Request parameters for standalone CMT data import. -/// -public sealed record CmtImportRequest( - /// Path to the CMT data package (.zip file or extracted folder containing data.xml and data_schema.xml). - string DataPath, - - /// Dataverse connection string. Mutually exclusive with . - string? ConnectionString, - - /// Dataverse environment URL for interactive auth. - string? EnvironmentUrl, - - /// Use device code flow instead of browser for interactive auth. - bool DeviceCode, - - /// Number of parallel import connections. Defaults to 1. - int ConnectionCount, - - /// Enable verbose CMT trace output. - bool Verbose); diff --git a/src/TALXIS.CLI.XrmTools/DataverseInteractiveAuthHook.cs b/src/TALXIS.CLI.XrmTools/DataverseInteractiveAuthHook.cs deleted file mode 100644 index 38325fb..0000000 --- a/src/TALXIS.CLI.XrmTools/DataverseInteractiveAuthHook.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.Xrm.Tooling.Connector; -using TALXIS.CLI.Dataverse; - -namespace TALXIS.CLI.XrmTools; - -/// -/// Legacy adapter for the CrmServiceClient shim. -/// Delegates all token acquisition to the modern -/// which lives in TALXIS.CLI.Dataverse. -/// -public sealed class DataverseInteractiveAuthHook : IOverrideAuthHookWrapper, IDisposable -{ - private readonly DataverseAuthTokenProvider _tokenProvider; - private readonly bool _ownsTokenProvider; - - public DataverseInteractiveAuthHook(Uri environmentUrl, bool deviceCode, bool verbose) - : this(new DataverseAuthTokenProvider(environmentUrl, deviceCode, verbose), ownsTokenProvider: true) - { - } - - public DataverseInteractiveAuthHook(DataverseAuthTokenProvider tokenProvider) - : this(tokenProvider, ownsTokenProvider: false) - { - } - - private DataverseInteractiveAuthHook(DataverseAuthTokenProvider tokenProvider, bool ownsTokenProvider) - { - ArgumentNullException.ThrowIfNull(tokenProvider); - _tokenProvider = tokenProvider; - _ownsTokenProvider = ownsTokenProvider; - } - - public string GetAuthToken(Uri connectedUri) - { - ArgumentNullException.ThrowIfNull(connectedUri); - return _tokenProvider.GetAccessTokenAsync(connectedUri).GetAwaiter().GetResult(); - } - - public void Dispose() - { - if (_ownsTokenProvider) - { - _tokenProvider.Dispose(); - } - } -} diff --git a/src/TALXIS.CLI/Program.cs b/src/TALXIS.CLI/Program.cs index 312a44e..0bd276a 100644 --- a/src/TALXIS.CLI/Program.cs +++ b/src/TALXIS.CLI/Program.cs @@ -1,5 +1,6 @@ using DotMake.CommandLine; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.DependencyInjection; +using TALXIS.CLI.Platform.Dataverse.Platforms; namespace TALXIS.CLI { @@ -7,12 +8,18 @@ public class Program { public static async Task Main(string[] args) { - int? packageDeployerExitCode = await PackageDeployerSubprocess.TryRunAsync(args); + int? packageDeployerExitCode = await LegacyAssemblyHostSubprocess.TryRunAsync(args); if (packageDeployerExitCode.HasValue) { return packageDeployerExitCode.Value; } + // Bootstrap txc-config DI so every [CliCommand] handler can resolve + // stores/resolvers/vault through TxcServices.Get(). Done once per + // process. The PackageDeployer subprocess branch short-circuits above + // and wires its own TxcServices via the same bootstrap helper. + TxcServicesBootstrap.EnsureInitialized(); + return await Cli.RunAsync(args, new CliSettings { EnableDefaultExceptionHandler = true }); } diff --git a/src/TALXIS.CLI/TALXIS.CLI.csproj b/src/TALXIS.CLI/TALXIS.CLI.csproj index b7393e4..e6ea7c1 100644 --- a/src/TALXIS.CLI/TALXIS.CLI.csproj +++ b/src/TALXIS.CLI/TALXIS.CLI.csproj @@ -4,11 +4,14 @@ - - - + + + - + + + + diff --git a/src/TALXIS.CLI/TxcCliCommand.cs b/src/TALXIS.CLI/TxcCliCommand.cs index 2e98b0f..b1501d8 100644 --- a/src/TALXIS.CLI/TxcCliCommand.cs +++ b/src/TALXIS.CLI/TxcCliCommand.cs @@ -4,7 +4,7 @@ namespace TALXIS.CLI; [CliCommand( Description = "Tool for automating development loops in Power Platform", - Children = new[] { typeof(Data.DataCliCommand), typeof(Environment.EnvironmentCliCommand), typeof(Docs.DocsCliCommand), typeof(Workspace.WorkspaceCliCommand) }, + Children = new[] { typeof(TALXIS.CLI.Features.Data.DataCliCommand), typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand), typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand), typeof(TALXIS.CLI.Features.Config.ConfigCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class TxcCliCommand diff --git a/tests/TALXIS.CLI.IntegrationTests/CliRunner.cs b/tests/TALXIS.CLI.IntegrationTests/CliRunner.cs index 220a83e..ac5e968 100644 --- a/tests/TALXIS.CLI.IntegrationTests/CliRunner.cs +++ b/tests/TALXIS.CLI.IntegrationTests/CliRunner.cs @@ -29,9 +29,9 @@ public static Task RunAsync(string command, string? workingDirectory = n /// Runs a CLI command with explicit argument tokens. /// Throws on non-zero exit code. /// - public static async Task RunAsync(string[] args, string? workingDirectory = null) + public static async Task RunAsync(string[] args, string? workingDirectory = null, System.Collections.Generic.IReadOnlyDictionary? env = null) { - var result = await RunRawAsync(args, workingDirectory); + var result = await RunRawAsync(args, workingDirectory, env); if (result.ExitCode != 0) { @@ -57,9 +57,21 @@ public static Task RunRawAsync(string command, string? workingDirecto /// /// Runs a CLI command with explicit argument tokens and returns the full result without throwing on failure. /// - public static async Task RunRawAsync(string[] args, string? workingDirectory = null) + public static async Task RunRawAsync(string[] args, string? workingDirectory = null, System.Collections.Generic.IReadOnlyDictionary? env = null) { var psi = CreateProcessStartInfo(args, workingDirectory); + + if (env is not null) + { + foreach (var kvp in env) + { + if (kvp.Value is null) + psi.Environment.Remove(kvp.Key); + else + psi.Environment[kvp.Key] = kvp.Value; + } + } + using var process = Process.Start(psi)!; // Read stdout and stderr concurrently to avoid deadlocks when diff --git a/tests/TALXIS.CLI.IntegrationTests/Config/ProfileEndToEndTests.cs b/tests/TALXIS.CLI.IntegrationTests/Config/ProfileEndToEndTests.cs new file mode 100644 index 0000000..46fdc23 --- /dev/null +++ b/tests/TALXIS.CLI.IntegrationTests/Config/ProfileEndToEndTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace TALXIS.CLI.IntegrationTests.Config; + +/// +/// End-to-end integration test for the txc config command surface. +/// Uses an isolated TXC_CONFIG_DIR so the test never touches the +/// developer's real ~/.txc. Exercises the full profile lifecycle: +/// connection create → auth add-service-principal → profile create → +/// profile select → profile show → profile list → profile delete. +/// +/// The SPN credential is registered via the --secret-from-env +/// code path so the test stays fully non-interactive and portable across +/// local dev, CI, and headless runners. No live Dataverse call is made — +/// profile validate is intentionally omitted because it would try +/// to reach the fake environment URL. +/// +[Collection("Sequential")] +public class ProfileEndToEndTests : IDisposable +{ + private readonly string _configDir; + private readonly IReadOnlyDictionary _env; + + public ProfileEndToEndTests() + { + _configDir = Path.Combine(Path.GetTempPath(), "txc-e2e-" + Path.GetRandomFileName()); + Directory.CreateDirectory(_configDir); + _env = new Dictionary + { + ["TXC_CONFIG_DIR"] = _configDir, + // Force a fallback secret vault so the test never prompts for + // Keychain access on macOS or tries to reach libsecret on a + // headless Linux runner. + ["TXC_PLAINTEXT_FALLBACK"] = "1", + ["TXC_TOKEN_CACHE_MODE"] = "file", + ["TXC_NON_INTERACTIVE"] = "1", + ["TXC_E2E_TEST_SECRET"] = "not-a-real-secret-placeholder-12345", + }; + } + + public void Dispose() + { + try { Directory.Delete(_configDir, recursive: true); } catch { } + } + + [Fact] + public async Task Profile_Lifecycle_RoundTrip() + { + // 1. Create a connection. + var create = await CliRunner.RunRawAsync( + new[] { "config", "connection", "create", "e2e-conn", + "--provider", "dataverse", + "--environment", "https://contoso.crm4.dynamics.com/", + "--cloud", "Public" }, + env: _env); + Assert.True(create.ExitCode == 0, $"connection create failed: {create.Error}\n{create.Output}"); + + // 2. Register a client-secret credential via the env-var path. + var auth = await CliRunner.RunRawAsync( + new[] { "config", "auth", "add-service-principal", + "--alias", "e2e-sp", + "--tenant", "11111111-1111-1111-1111-111111111111", + "--application-id", "22222222-2222-2222-2222-222222222222", + "--secret-from-env", "TXC_E2E_TEST_SECRET" }, + env: _env); + Assert.True(auth.ExitCode == 0, $"auth add-service-principal failed: {auth.Error}\n{auth.Output}"); + + // 3. Bind them into a profile. + var profile = await CliRunner.RunRawAsync( + new[] { "config", "profile", "create", "e2e-profile", + "--auth", "e2e-sp", + "--connection", "e2e-conn" }, + env: _env); + Assert.True(profile.ExitCode == 0, $"profile create failed: {profile.Error}\n{profile.Output}"); + + // 4. List should contain the new profile. + var list = await CliRunner.RunRawAsync(new[] { "config", "profile", "list" }, env: _env); + Assert.Equal(0, list.ExitCode); + Assert.Contains("e2e-profile", list.Output); + + // 5. Select it. + var select = await CliRunner.RunRawAsync( + new[] { "config", "profile", "select", "e2e-profile" }, env: _env); + Assert.Equal(0, select.ExitCode); + + // 6. Show includes connection + credential refs. + var show = await CliRunner.RunRawAsync( + new[] { "config", "profile", "show", "e2e-profile" }, env: _env); + Assert.Equal(0, show.ExitCode); + Assert.Contains("e2e-sp", show.Output); + Assert.Contains("e2e-conn", show.Output); + + // 7. Cleanup — delete profile, credential (with vault secret), and connection. + var delProfile = await CliRunner.RunRawAsync( + new[] { "config", "profile", "delete", "e2e-profile" }, env: _env); + Assert.Equal(0, delProfile.ExitCode); + + var delAuth = await CliRunner.RunRawAsync( + new[] { "config", "auth", "delete", "e2e-sp" }, env: _env); + Assert.Equal(0, delAuth.ExitCode); + + var delConn = await CliRunner.RunRawAsync( + new[] { "config", "connection", "delete", "e2e-conn" }, env: _env); + Assert.Equal(0, delConn.ExitCode); + } + + [Fact] + public async Task Profile_Show_MissingProfile_FailsFast() + { + // No profile exists; show should fail with a clear non-zero exit + // rather than hanging or prompting. + var result = await CliRunner.RunRawAsync( + new[] { "config", "profile", "show", "does-not-exist" }, env: _env); + Assert.NotEqual(0, result.ExitCode); + } +} diff --git a/tests/TALXIS.CLI.IntegrationTests/EnvironmentInstallTests.cs b/tests/TALXIS.CLI.IntegrationTests/EnvironmentInstallTests.cs index 5761d1e..8468601 100644 --- a/tests/TALXIS.CLI.IntegrationTests/EnvironmentInstallTests.cs +++ b/tests/TALXIS.CLI.IntegrationTests/EnvironmentInstallTests.cs @@ -2,10 +2,9 @@ using System.Net; using System.Net.Http; using System.Text; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment; -using TALXIS.CLI.Environment.Platforms.Dataverse; -using TALXIS.CLI.XrmTools; +using TALXIS.CLI.Platform.Dataverse; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Core.Platforms.Packaging; using Xunit; namespace TALXIS.CLI.IntegrationTests; @@ -73,22 +72,6 @@ public void Dispose() } } - [Fact] - public void BuildDefaultScope_UsesPacCompatibleDoubleSlashSeparator() - { - string scope = DataverseAuthTokenProvider.BuildDefaultScope(new Uri("https://org2928f636.crm.dynamics.com/main.aspx")); - - Assert.Equal("https://org2928f636.crm.dynamics.com//.default", scope); - } - - [Fact] - public void ResolveAuthority_UsesPublicCloudAuthorityForDynamicsCom() - { - Uri authority = DataverseAuthTokenProvider.ResolveAuthority(new Uri("https://org2928f636.crm.dynamics.com")); - - Assert.Equal(new Uri("https://login.microsoftonline.com/organizations"), authority); - } - private static void CreateTestNuGetPackage(string packagePath, string innerPath, string content) { Directory.CreateDirectory(Path.GetDirectoryName(packagePath)!); diff --git a/tests/TALXIS.CLI.IntegrationTests/TALXIS.CLI.IntegrationTests.csproj b/tests/TALXIS.CLI.IntegrationTests/TALXIS.CLI.IntegrationTests.csproj index 84ee31f..dac5b3c 100644 --- a/tests/TALXIS.CLI.IntegrationTests/TALXIS.CLI.IntegrationTests.csproj +++ b/tests/TALXIS.CLI.IntegrationTests/TALXIS.CLI.IntegrationTests.csproj @@ -21,8 +21,8 @@ - - + + diff --git a/tests/TALXIS.CLI.Tests/Config/Bootstrapping/ProfileCreateOneLinerTests.cs b/tests/TALXIS.CLI.Tests/Config/Bootstrapping/ProfileCreateOneLinerTests.cs new file mode 100644 index 0000000..e33043f --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Bootstrapping/ProfileCreateOneLinerTests.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Profile; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core; +using TALXIS.CLI.Tests.Config.Commands; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Bootstrapping; + +[Collection("TxcServicesSerial")] +public sealed class ProfileCreateOneLinerTests +{ + [Fact] + public async Task UrlMode_Bootstraps_Credential_Connection_Profile_AndActivates() + { + using var host = new CommandTestHost(); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + exit = await new ProfileCreateCliCommand + { + Url = "https://contoso.crm4.dynamics.com/", + }.RunAsync(); + + Assert.Equal(0, exit); + + var profiles = (IProfileStore)host.Provider.GetService(typeof(IProfileStore))!; + var connections = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + var creds = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + var global = (IGlobalConfigStore)host.Provider.GetService(typeof(IGlobalConfigStore))!; + + var profile = await profiles.GetAsync("contoso", default); + Assert.NotNull(profile); + Assert.Equal("contoso", profile!.ConnectionRef); + var connection = await connections.GetAsync("contoso", default); + Assert.NotNull(connection); + Assert.Equal(ProviderKind.Dataverse, connection!.Provider); + var credential = await creds.GetAsync(profile.CredentialRef!, default); + Assert.NotNull(credential); + Assert.Equal(CredentialKind.InteractiveBrowser, credential!.Kind); + + var cfg = await global.LoadAsync(default); + Assert.Equal("contoso", cfg.ActiveProfile); + + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal("contoso", doc.RootElement.GetProperty("id").GetString()); + Assert.Equal("tomas@contoso.com", doc.RootElement.GetProperty("upn").GetString()); + } + + [Fact] + public async Task UrlMode_DerivedName_CollidesWithExistingConnection_PicksSuffix() + { + using var host = new CommandTestHost(); + var connections = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + await connections.UpsertAsync(new Connection { Id = "contoso", Provider = ProviderKind.Dataverse, EnvironmentUrl = "https://existing" }, default); + + using (OutputWriter.RedirectTo(new StringWriter())) + { + var exit = await new ProfileCreateCliCommand + { + Url = "https://contoso.crm.dynamics.com/", + }.RunAsync(); + Assert.Equal(0, exit); + } + + var profiles = (IProfileStore)host.Provider.GetService(typeof(IProfileStore))!; + Assert.NotNull(await profiles.GetAsync("contoso-2", default)); + } + + [Fact] + public async Task UrlMode_UnknownHost_WithoutProvider_Fails() + { + using var host = new CommandTestHost(); + using (OutputWriter.RedirectTo(new StringWriter())) + { + var exit = await new ProfileCreateCliCommand + { + Url = "https://example.invalid/", + }.RunAsync(); + Assert.Equal(1, exit); + } + } + + [Fact] + public async Task UrlMode_WithAuthOrConnection_IsRejectedAsMixedMode() + { + using var host = new CommandTestHost(); + using (OutputWriter.RedirectTo(new StringWriter())) + { + var exit = await new ProfileCreateCliCommand + { + Url = "https://contoso.crm.dynamics.com/", + Auth = "some-cred", + }.RunAsync(); + Assert.Equal(1, exit); + } + } + + [Fact] + public async Task UrlMode_Headless_FailsWithHeadlessError() + { + using var host = new CommandTestHost(headless: true); + using (OutputWriter.RedirectTo(new StringWriter())) + { + var exit = await new ProfileCreateCliCommand + { + Url = "https://contoso.crm.dynamics.com/", + }.RunAsync(); + Assert.Equal(1, exit); + } + + // No profile was created. + var profiles = (IProfileStore)host.Provider.GetService(typeof(IProfileStore))!; + Assert.Null(await profiles.GetAsync("contoso", default)); + } + + [Fact] + public async Task UrlMode_ExplicitName_OverridesDerived() + { + using var host = new CommandTestHost(); + using (OutputWriter.RedirectTo(new StringWriter())) + { + var exit = await new ProfileCreateCliCommand + { + Url = "https://contoso.crm.dynamics.com/", + Name = "my-profile", + }.RunAsync(); + Assert.Equal(0, exit); + } + + var profiles = (IProfileStore)host.Provider.GetService(typeof(IProfileStore))!; + var connections = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + Assert.NotNull(await profiles.GetAsync("my-profile", default)); + Assert.NotNull(await connections.GetAsync("my-profile", default)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Bootstrapping/ProviderUrlResolverTests.cs b/tests/TALXIS.CLI.Tests/Config/Bootstrapping/ProviderUrlResolverTests.cs new file mode 100644 index 0000000..87c5f91 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Bootstrapping/ProviderUrlResolverTests.cs @@ -0,0 +1,63 @@ +using TALXIS.CLI.Core.Bootstrapping; +using TALXIS.CLI.Core.Model; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Bootstrapping; + +public class ProviderUrlResolverTests +{ + [Theory] + [InlineData("https://contoso.crm4.dynamics.com/", ProviderKind.Dataverse)] + [InlineData("https://contoso.crm.dynamics.com", ProviderKind.Dataverse)] + [InlineData("https://contoso.crm.microsoftdynamics.us", ProviderKind.Dataverse)] + [InlineData("https://contoso.crm.appsplatform.us", ProviderKind.Dataverse)] + [InlineData("https://contoso.crm.dynamics.cn", ProviderKind.Dataverse)] + public void Infer_ResolvesDataverseHosts(string url, ProviderKind expected) + { + var r = ProviderUrlResolver.Infer(url); + Assert.Equal(expected, r.Provider); + Assert.Null(r.Error); + } + + [Theory] + [InlineData("https://example.com/")] + [InlineData("https://contoso.crm4.dynamics.fake")] + public void Infer_ReturnsErrorForUnknownHost(string url) + { + var r = ProviderUrlResolver.Infer(url); + Assert.Null(r.Provider); + Assert.NotNull(r.Error); + Assert.Contains("Known host suffixes", r.Error); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not a url")] + [InlineData("ftp://contoso.crm.dynamics.com")] + public void Infer_ReturnsErrorForInvalidUrl(string? url) + { + var r = ProviderUrlResolver.Infer(url); + Assert.Null(r.Provider); + Assert.NotNull(r.Error); + } + + [Theory] + [InlineData("https://contoso.crm4.dynamics.com/", "contoso")] + [InlineData("https://contoso-dev.crm.dynamics.com", "contoso-dev")] + [InlineData("https://CONTOSO.crm.dynamics.com/", "contoso")] + [InlineData("https://weird__name.crm.dynamics.com", "weird-name")] + public void DeriveDefaultName_UsesFirstDnsLabel(string url, string expected) + { + Assert.Equal(expected, ProviderUrlResolver.DeriveDefaultName(url)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not a url")] + public void DeriveDefaultName_ReturnsNullForInvalid(string? url) + { + Assert.Null(ProviderUrlResolver.DeriveDefaultName(url)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthAddServicePrincipalCommandTests.cs b/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthAddServicePrincipalCommandTests.cs new file mode 100644 index 0000000..7f51a12 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthAddServicePrincipalCommandTests.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Auth; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Commands.Auth; + +[Collection("TxcServicesSerial")] +public sealed class AuthAddServicePrincipalCommandTests +{ + [Fact] + public async Task AddSp_ReadsSecretFromEnvVar_AndPersistsVaultEntry() + { + const string envName = "TXC_TEST_SP_SECRET_A"; + System.Environment.SetEnvironmentVariable(envName, "super-secret-1"); + try + { + using var host = new CommandTestHost(); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + exit = await new AuthAddServicePrincipalCliCommand + { + Alias = "contoso-sp", + Tenant = "contoso.onmicrosoft.com", + ApplicationId = "11111111-1111-1111-1111-111111111111", + Description = "Prod SP", + SecretFromEnv = envName, + }.RunAsync(); + + Assert.Equal(0, exit); + + var store = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + var cred = await store.GetAsync("contoso-sp", default); + Assert.NotNull(cred); + Assert.Equal(CredentialKind.ClientSecret, cred!.Kind); + Assert.Equal("contoso.onmicrosoft.com", cred.TenantId); + Assert.Equal("11111111-1111-1111-1111-111111111111", cred.ApplicationId); + Assert.Equal(CloudInstance.Public, cred.Cloud); + Assert.Equal("Prod SP", cred.Description); + Assert.NotNull(cred.SecretRef); + Assert.Equal("contoso-sp", cred.SecretRef!.CredentialId); + Assert.Equal("client-secret", cred.SecretRef.Slot); + + Assert.Equal("super-secret-1", host.Vault.Contents[cred.SecretRef.Uri]); + + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal("contoso-sp", doc.RootElement.GetProperty("id").GetString()); + Assert.False(doc.RootElement.TryGetProperty("secret", out _)); + } + finally + { + System.Environment.SetEnvironmentVariable(envName, null); + } + } + + [Fact] + public async Task AddSp_FailsWhenEnvVarUnset() + { + using var host = new CommandTestHost(); + var exit = await new AuthAddServicePrincipalCliCommand + { + Alias = "sp-missing", + Tenant = "t", + ApplicationId = "a", + SecretFromEnv = "TXC_DEFINITELY_UNSET_VAR_XYZ", + }.RunAsync(); + Assert.Equal(1, exit); + + var store = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + Assert.Null(await store.GetAsync("sp-missing", default)); + Assert.Empty(host.Vault.Contents); + } + + [Fact] + public async Task AddSp_ReadsSecretFromPipedStdin() + { + AuthAddServicePrincipalCliCommand.StdinOverride = new StringReader("piped-secret-value\n"); + try + { + using var host = new CommandTestHost(); + var exit = await new AuthAddServicePrincipalCliCommand + { + Alias = "piped-sp", + Tenant = "t", + ApplicationId = "a", + }.RunAsync(); + Assert.Equal(0, exit); + + Assert.Equal("piped-secret-value", + host.Vault.Contents[SecretRef.Create("piped-sp", "client-secret").Uri]); + } + finally + { + AuthAddServicePrincipalCliCommand.StdinOverride = null; + } + } + + [Fact] + public async Task AddSp_WorksInHeadlessMode_SinceClientSecretIsPermittedThere() + { + using var host = new CommandTestHost(headless: true); + System.Environment.SetEnvironmentVariable("TXC_TEST_SP_SECRET_B", "hl-secret"); + try + { + var exit = await new AuthAddServicePrincipalCliCommand + { + Alias = "sp-hl", + Tenant = "t", + ApplicationId = "a", + SecretFromEnv = "TXC_TEST_SP_SECRET_B", + }.RunAsync(); + Assert.Equal(0, exit); + Assert.Equal("hl-secret", + host.Vault.Contents[SecretRef.Create("sp-hl", "client-secret").Uri]); + } + finally + { + System.Environment.SetEnvironmentVariable("TXC_TEST_SP_SECRET_B", null); + } + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthCrudCommandsTests.cs b/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthCrudCommandsTests.cs new file mode 100644 index 0000000..d52f956 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthCrudCommandsTests.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Auth; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core; +using Xunit; +using ProfileModel = TALXIS.CLI.Core.Model.Profile; + +namespace TALXIS.CLI.Tests.Config.Commands.Auth; + +[Collection("TxcServicesSerial")] +public sealed class AuthCrudCommandsTests +{ + private static async Task SeedAsync(ICredentialStore store, params Credential[] creds) + { + foreach (var c in creds) + await store.UpsertAsync(c, default); + } + + [Fact] + public async Task List_EmitsJsonArray_OfStoredCredentials() + { + using var host = new CommandTestHost(); + var store = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + await SeedAsync(store, + new Credential { Id = "dev", Kind = CredentialKind.InteractiveBrowser, TenantId = "t1" }, + new Credential { Id = "prod", Kind = CredentialKind.ClientSecret, TenantId = "t2", ApplicationId = "app2" }); + + var sw = new StringWriter(); + using (OutputWriter.RedirectTo(sw)) + { + var exit = await new AuthListCliCommand().RunAsync(); + Assert.Equal(0, exit); + } + + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(2, doc.RootElement.GetArrayLength()); + var ids = doc.RootElement.EnumerateArray().Select(e => e.GetProperty("id").GetString()).ToHashSet(); + Assert.Contains("dev", ids); + Assert.Contains("prod", ids); + } + + [Fact] + public async Task Show_ReturnsCredentialJson_WhenFound() + { + using var host = new CommandTestHost(); + var store = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + await SeedAsync(store, + new Credential { Id = "dev", Kind = CredentialKind.InteractiveBrowser, TenantId = "t1", Description = "Dev tenant" }); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + exit = await new AuthShowCliCommand { Alias = "dev" }.RunAsync(); + + Assert.Equal(0, exit); + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal("dev", doc.RootElement.GetProperty("id").GetString()); + Assert.Equal("Dev tenant", doc.RootElement.GetProperty("description").GetString()); + } + + [Fact] + public async Task Show_ReturnsTwo_WhenAliasMissing() + { + using var host = new CommandTestHost(); + var exit = await new AuthShowCliCommand { Alias = "nope" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Delete_RemovesCredentialAndVaultSecret() + { + using var host = new CommandTestHost(); + var store = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + var cred = new Credential + { + Id = "spn", + Kind = CredentialKind.ClientSecret, + TenantId = "t", + ApplicationId = "app", + SecretRef = SecretRef.Create("spn", "client-secret"), + }; + await SeedAsync(store, cred); + await host.Vault.SetSecretAsync(cred.SecretRef, "super-secret", default); + + var exit = await new AuthDeleteCliCommand { Alias = "spn" }.RunAsync(); + Assert.Equal(0, exit); + + var listed = await store.ListAsync(default); + Assert.Empty(listed); + Assert.Empty(host.Vault.Contents); + } + + [Fact] + public async Task Delete_ReturnsTwo_WhenAliasMissing() + { + using var host = new CommandTestHost(); + var exit = await new AuthDeleteCliCommand { Alias = "nope" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Delete_LeavesProfilesOrphaned_WithWarning() + { + using var host = new CommandTestHost(); + var credStore = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + var profileStore = (IProfileStore)host.Provider.GetService(typeof(IProfileStore))!; + + await SeedAsync(credStore, new Credential { Id = "dev", Kind = CredentialKind.InteractiveBrowser }); + await profileStore.UpsertAsync( + new ProfileModel { Id = "demo", ConnectionRef = "conn", CredentialRef = "dev" }, + default); + + var exit = await new AuthDeleteCliCommand { Alias = "dev" }.RunAsync(); + Assert.Equal(0, exit); + + // Profile still present, just orphaned. + var profiles = await profileStore.ListAsync(default); + Assert.Single(profiles); + Assert.Equal("dev", profiles[0].CredentialRef); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthLoginCommandTests.cs b/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthLoginCommandTests.cs new file mode 100644 index 0000000..3680e08 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthLoginCommandTests.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Bootstrapping; +using TALXIS.CLI.Features.Config.Auth; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Commands.Auth; + +[Collection("TxcServicesSerial")] +public sealed class AuthLoginCommandTests +{ + [Fact] + public async Task Login_PersistsCredential_WithUpnAlias() + { + using var host = new CommandTestHost( + loginResult: new InteractiveLoginResult("tomas@contoso.com", "t-guid")); + var store = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + exit = await new AuthLoginCliCommand().RunAsync(); + + Assert.Equal(0, exit); + Assert.Equal(1, host.Login.Calls); + Assert.Null(host.Login.LastTenant); + Assert.Equal(CloudInstance.Public, host.Login.LastCloud); + + var creds = await store.ListAsync(default); + var cred = Assert.Single(creds); + Assert.Equal("tomas@contoso.com", cred.Id); + Assert.Equal(CredentialKind.InteractiveBrowser, cred.Kind); + Assert.Equal("t-guid", cred.TenantId); + Assert.Equal(CloudInstance.Public, cred.Cloud); + + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal("tomas@contoso.com", doc.RootElement.GetProperty("id").GetString()); + Assert.Equal("tomas@contoso.com", doc.RootElement.GetProperty("upn").GetString()); + } + + [Fact] + public async Task Login_HonorsExplicitAliasAndTenantAndCloud() + { + using var host = new CommandTestHost( + loginResult: new InteractiveLoginResult("admin@fabrikam.onmicrosoft.com", "fab-t")); + + var sw = new StringWriter(); + using (OutputWriter.RedirectTo(sw)) + { + var exit = await new AuthLoginCliCommand + { + Alias = "prod", + Tenant = "fabrikam.onmicrosoft.com", + Cloud = CloudInstance.GccHigh, + }.RunAsync(); + Assert.Equal(0, exit); + } + + Assert.Equal("fabrikam.onmicrosoft.com", host.Login.LastTenant); + Assert.Equal(CloudInstance.GccHigh, host.Login.LastCloud); + + var store = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + var cred = await store.GetAsync("prod", default); + Assert.NotNull(cred); + Assert.Equal(CloudInstance.GccHigh, cred!.Cloud); + } + + [Fact] + public async Task Login_AppendsTenantShortName_OnAliasCollision() + { + using var host = new CommandTestHost( + loginResult: new InteractiveLoginResult("tomas@contoso.com", "t-guid")); + var store = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + await store.UpsertAsync( + new Credential { Id = "tomas@contoso.com", Kind = CredentialKind.DeviceCode }, + default); + + var exit = await new AuthLoginCliCommand().RunAsync(); + Assert.Equal(0, exit); + + var creds = await store.ListAsync(default); + Assert.Equal(2, creds.Count); + Assert.Contains(creds, c => c.Id == "tomas@contoso.com-contoso"); + } + + [Fact] + public async Task Login_FallsBackToNumericSuffix_WhenTenantShortNameCollidesToo() + { + using var host = new CommandTestHost( + loginResult: new InteractiveLoginResult("tomas@contoso.com", "t-guid")); + var store = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + await store.UpsertAsync(new Credential { Id = "tomas@contoso.com", Kind = CredentialKind.DeviceCode }, default); + await store.UpsertAsync(new Credential { Id = "tomas@contoso.com-contoso", Kind = CredentialKind.DeviceCode }, default); + + var exit = await new AuthLoginCliCommand().RunAsync(); + Assert.Equal(0, exit); + var creds = await store.ListAsync(default); + Assert.Contains(creds, c => c.Id == "tomas@contoso.com-2"); + } + + [Fact] + public async Task Login_FailsFast_InHeadlessEnvironment() + { + using var host = new CommandTestHost(headless: true); + var exit = await new AuthLoginCliCommand().RunAsync(); + Assert.Equal(1, exit); + Assert.Equal(0, host.Login.Calls); + } + + [Theory] + [InlineData("tomas@contoso.com", "contoso")] + [InlineData("jane@fabrikam.onmicrosoft.com", "fabrikam")] + [InlineData("ops@tenant-name.co.uk", "tenant-name")] + [InlineData("noatsign", null)] + [InlineData("trailing@", null)] + [InlineData("", null)] + public void ExtractTenantShortName_HandlesCommonShapes(string upn, string? expected) + { + Assert.Equal(expected, CredentialAliasResolver.ExtractTenantShortName(upn)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthLoginResolveDefaultAliasTests.cs b/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthLoginResolveDefaultAliasTests.cs new file mode 100644 index 0000000..fbfe191 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/Auth/AuthLoginResolveDefaultAliasTests.cs @@ -0,0 +1,35 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Bootstrapping; +using TALXIS.CLI.Core.Model; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Commands.Auth; + +public class AuthLoginResolveDefaultAliasTests +{ + [Fact] + public async Task PassesCancellationTokenToStore() + { + var store = new CancellingStore(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => CredentialAliasResolver.ResolveForUpnAsync(store, "alice@contoso.com", cts.Token)); + } + + private sealed class CancellingStore : ICredentialStore + { + public Task> ListAsync(CancellationToken ct) => + throw new NotSupportedException(); + public Task GetAsync(string id, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(null); + } + public Task UpsertAsync(Credential credential, CancellationToken ct) => + throw new NotSupportedException(); + public Task DeleteAsync(string id, CancellationToken ct) => + throw new NotSupportedException(); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/CommandTestHost.cs b/tests/TALXIS.CLI.Tests/Config/Commands/CommandTestHost.cs new file mode 100644 index 0000000..c3b315d --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/CommandTestHost.cs @@ -0,0 +1,151 @@ +using Microsoft.Extensions.DependencyInjection; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Core.Storage; +using ConnectionModel = TALXIS.CLI.Core.Model.Connection; + +namespace TALXIS.CLI.Tests.Config.Commands;/// +/// Per-test TxcServices scope: boots an in-memory service provider backed +/// by a temp config dir + a fake in-memory vault, and tears it down on +/// dispose. Commands resolve their dependencies through TxcServices, +/// which is process-wide, so tests must guard the lifecycle carefully. +/// +internal sealed class CommandTestHost : IDisposable +{ + public TempConfigDir Temp { get; } + public ServiceProvider Provider { get; } + public FakeVault Vault { get; } + public FakeHeadless Headless { get; } + public FakeInteractiveLogin Login { get; } + public FakeConnectionProvider Provider_Dataverse { get; } + + public CommandTestHost( + bool headless = false, + InteractiveLoginResult? loginResult = null, + string? currentDirectory = null, + FakeConnectionProvider? dataverseProvider = null) + { + Temp = new TempConfigDir(); + Vault = new FakeVault(); + Headless = new FakeHeadless(headless); + Login = new FakeInteractiveLogin(loginResult + ?? new InteractiveLoginResult("tomas@contoso.com", "tenant-guid")); + Provider_Dataverse = dataverseProvider ?? new FakeConnectionProvider(ProviderKind.Dataverse); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Temp.Paths); + services.AddSingleton( + currentDirectory is null + ? ProcessEnvironmentReader.Instance + : new FakeEnvironmentReader(currentDirectory)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(Vault); + services.AddSingleton(Headless); + services.AddSingleton(Login); + services.AddSingleton(Provider_Dataverse); + services.AddSingleton< + TALXIS.CLI.Core.Bootstrapping.IConnectionProviderBootstrapper, + TALXIS.CLI.Platform.Dataverse.Bootstrapping.DataverseConnectionProviderBootstrapper>(); + + Provider = services.BuildServiceProvider(); + TxcServices.Initialize(Provider); + } + + public void Dispose() + { + TxcServices.Reset(); + Provider.Dispose(); + Temp.Dispose(); + } + + internal sealed class FakeConnectionProvider : IConnectionProvider + { + private static readonly HashSet DefaultKinds = new() + { + CredentialKind.InteractiveBrowser, + CredentialKind.DeviceCode, + CredentialKind.ClientSecret, + CredentialKind.ClientCertificate, + CredentialKind.WorkloadIdentityFederation, + CredentialKind.ManagedIdentity, + CredentialKind.AzureCli, + }; + + public FakeConnectionProvider(ProviderKind kind) { ProviderKind = kind; } + + public ProviderKind ProviderKind { get; } + public IReadOnlySet SupportedCredentialKinds => DefaultKinds; + public int Calls { get; private set; } + public ValidationMode? LastMode { get; private set; } + public Func? Behavior { get; set; } + + public Task ValidateAsync(ConnectionModel connection, Credential credential, ValidationMode mode, CancellationToken ct) + { + Calls++; + LastMode = mode; + if (Behavior is not null) return Behavior(connection, credential, mode); + return Task.CompletedTask; + } + } + + internal sealed class FakeEnvironmentReader : IEnvironmentReader + { + private readonly string _cwd; + public FakeEnvironmentReader(string cwd) { _cwd = cwd; } + public string? Get(string name) => System.Environment.GetEnvironmentVariable(name); + public string GetCurrentDirectory() => _cwd; + } + + internal sealed class FakeVault : ICredentialVault + { + private readonly Dictionary _store = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyDictionary Contents => _store; + + public Task GetSecretAsync(SecretRef reference, CancellationToken ct) + => Task.FromResult(_store.TryGetValue(reference.Uri, out var v) ? v : null); + + public Task SetSecretAsync(SecretRef reference, string value, CancellationToken ct) + { + _store[reference.Uri] = value; + return Task.CompletedTask; + } + + public Task DeleteSecretAsync(SecretRef reference, CancellationToken ct) + => Task.FromResult(_store.Remove(reference.Uri)); + } + + internal sealed class FakeHeadless : IHeadlessDetector + { + public FakeHeadless(bool isHeadless) { IsHeadless = isHeadless; Reason = isHeadless ? "test harness" : null; } + public bool IsHeadless { get; } + public string? Reason { get; } + } + + internal sealed class FakeInteractiveLogin : IInteractiveLoginService + { + private readonly InteractiveLoginResult _result; + public int Calls { get; private set; } + public string? LastTenant { get; private set; } + public CloudInstance? LastCloud { get; private set; } + + public FakeInteractiveLogin(InteractiveLoginResult result) { _result = result; } + + public Task LoginAsync(string? tenantId, CloudInstance cloud, CancellationToken ct) + { + Calls++; + LastTenant = tenantId; + LastCloud = cloud; + return Task.FromResult(_result); + } + } +} + diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/Connection/ConnectionCommandsTests.cs b/tests/TALXIS.CLI.Tests/Config/Commands/Connection/ConnectionCommandsTests.cs new file mode 100644 index 0000000..a9389dc --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/Connection/ConnectionCommandsTests.cs @@ -0,0 +1,218 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Connection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core; +using Xunit; +using ConnectionModel = TALXIS.CLI.Core.Model.Connection; +using ProfileModel = TALXIS.CLI.Core.Model.Profile; + +namespace TALXIS.CLI.Tests.Config.Commands.Connection; + +[Collection("TxcServicesSerial")] +public sealed class ConnectionCommandsTests +{ + [Fact] + public async Task Create_Persists_DataverseConnection_AndEchoesJson() + { + using var host = new CommandTestHost(); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + exit = await new ConnectionCreateCliCommand + { + Name = "contoso-dev", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso-dev.crm.dynamics.com/", + Cloud = CloudInstance.Public, + OrganizationId = "11111111-1111-1111-1111-111111111111", + TenantId = "contoso.onmicrosoft.com", + Description = "Dev", + }.RunAsync(); + + Assert.Equal(0, exit); + + var store = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + var conn = await store.GetAsync("contoso-dev", default); + Assert.NotNull(conn); + Assert.Equal(ProviderKind.Dataverse, conn!.Provider); + Assert.Equal("https://contoso-dev.crm.dynamics.com", conn.EnvironmentUrl); + Assert.Equal(CloudInstance.Public, conn.Cloud); + Assert.Equal("11111111-1111-1111-1111-111111111111", conn.OrganizationId); + Assert.Equal("contoso.onmicrosoft.com", conn.TenantId); + + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal("contoso-dev", doc.RootElement.GetProperty("id").GetString()); + } + + [Fact] + public async Task Create_RejectsNonDataverseProvider_InV1() + { + using var host = new CommandTestHost(); + var exit = await new ConnectionCreateCliCommand + { + Name = "az", + Provider = ProviderKind.Azure, + EnvironmentUrl = "https://anything/", + }.RunAsync(); + Assert.Equal(1, exit); + + var store = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + Assert.Empty(await store.ListAsync(default)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-url")] + [InlineData("ftp://example.com")] + public async Task Create_Rejects_MissingOrInvalidEnvironmentUrl(string? url) + { + using var host = new CommandTestHost(); + var exit = await new ConnectionCreateCliCommand + { + Name = "bad", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = url, + }.RunAsync(); + Assert.Equal(1, exit); + } + + [Fact] + public async Task Create_Rejects_NonGuid_OrganizationId() + { + using var host = new CommandTestHost(); + var exit = await new ConnectionCreateCliCommand + { + Name = "org-bad", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com", + OrganizationId = "not-a-guid", + }.RunAsync(); + Assert.Equal(1, exit); + } + + [Fact] + public async Task List_EmitsJsonArray_OfStoredConnections() + { + using var host = new CommandTestHost(); + var store = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + await store.UpsertAsync(new ConnectionModel + { + Id = "a", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://a.crm.dynamics.com", + }, default); + await store.UpsertAsync(new ConnectionModel + { + Id = "b", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://b.crm.dynamics.com", + }, default); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + exit = await new ConnectionListCliCommand().RunAsync(); + Assert.Equal(0, exit); + + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal(2, doc.RootElement.GetArrayLength()); + } + + [Fact] + public async Task Show_ReturnsExit2_WhenMissing() + { + using var host = new CommandTestHost(); + var exit = await new ConnectionShowCliCommand { Name = "ghost" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Show_EmitsJson_WhenFound() + { + using var host = new CommandTestHost(); + var store = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + await store.UpsertAsync(new ConnectionModel + { + Id = "only", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://only.crm.dynamics.com", + }, default); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + exit = await new ConnectionShowCliCommand { Name = "only" }.RunAsync(); + Assert.Equal(0, exit); + + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal("only", doc.RootElement.GetProperty("id").GetString()); + } + + [Fact] + public async Task Delete_FailsWithExit3_WhenProfilesReference_AndNoForceFlag() + { + using var host = new CommandTestHost(); + var connStore = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + var profStore = (IProfileStore)host.Provider.GetService(typeof(IProfileStore))!; + await connStore.UpsertAsync(new ConnectionModel + { + Id = "c1", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://c1.crm.dynamics.com", + }, default); + await profStore.UpsertAsync(new ProfileModel + { + Id = "p1", + ConnectionRef = "c1", + CredentialRef = "whatever", + }, default); + + var exit = await new ConnectionDeleteCliCommand { Name = "c1" }.RunAsync(); + Assert.Equal(3, exit); + Assert.NotNull(await connStore.GetAsync("c1", default)); + } + + [Fact] + public async Task Delete_OrphansProfiles_WhenForceFlagIsSet() + { + using var host = new CommandTestHost(); + var connStore = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + var profStore = (IProfileStore)host.Provider.GetService(typeof(IProfileStore))!; + await connStore.UpsertAsync(new ConnectionModel + { + Id = "c1", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://c1.crm.dynamics.com", + }, default); + await profStore.UpsertAsync(new ProfileModel + { + Id = "p1", + ConnectionRef = "c1", + CredentialRef = "whatever", + }, default); + + var exit = await new ConnectionDeleteCliCommand + { + Name = "c1", + ForceOrphanProfiles = true, + }.RunAsync(); + Assert.Equal(0, exit); + Assert.Null(await connStore.GetAsync("c1", default)); + + // The orphaned profile is intentionally preserved — pac-auth-clear parity. + var p = await profStore.GetAsync("p1", default); + Assert.NotNull(p); + Assert.Equal("c1", p!.ConnectionRef); + } + + [Fact] + public async Task Delete_ReturnsExit2_WhenMissing() + { + using var host = new CommandTestHost(); + var exit = await new ConnectionDeleteCliCommand { Name = "ghost" }.RunAsync(); + Assert.Equal(2, exit); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/Profile/ProfileCommandsTests.cs b/tests/TALXIS.CLI.Tests/Config/Commands/Profile/ProfileCommandsTests.cs new file mode 100644 index 0000000..c3d86bd --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/Profile/ProfileCommandsTests.cs @@ -0,0 +1,307 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Profile; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core; +using Xunit; +using ConnectionModel = TALXIS.CLI.Core.Model.Connection; +using ProfileModel = TALXIS.CLI.Core.Model.Profile; + +namespace TALXIS.CLI.Tests.Config.Commands.Profile; + +[Collection("TxcServicesSerial")] +public sealed class ProfileCommandsTests +{ + private static async Task SeedAsync(CommandTestHost host, string credId = "cred-a", string connId = "conn-a") + { + var creds = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + var conns = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + await creds.UpsertAsync(new Credential + { + Id = credId, + Kind = CredentialKind.InteractiveBrowser, + TenantId = "contoso.onmicrosoft.com", + }, default); + await conns.UpsertAsync(new ConnectionModel + { + Id = connId, + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com", + }, default); + } + + [Fact] + public async Task Create_FirstProfile_AutoPromotesToActive() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + exit = await new ProfileCreateCliCommand + { + Name = "contoso-dev", + Auth = "cred-a", + Connection = "conn-a", + Description = "dev", + }.RunAsync(); + + Assert.Equal(0, exit); + + var global = (IGlobalConfigStore)host.Provider.GetService(typeof(IGlobalConfigStore))!; + var cfg = await global.LoadAsync(default); + Assert.Equal("contoso-dev", cfg.ActiveProfile); + + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.True(doc.RootElement.GetProperty("active").GetBoolean()); + } + + [Fact] + public async Task Create_SecondProfile_DoesNotReplaceActivePointer() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + + Assert.Equal(0, await new ProfileCreateCliCommand + { Name = "a", Auth = "cred-a", Connection = "conn-a" }.RunAsync()); + Assert.Equal(0, await new ProfileCreateCliCommand + { Name = "b", Auth = "cred-a", Connection = "conn-a" }.RunAsync()); + + var global = (IGlobalConfigStore)host.Provider.GetService(typeof(IGlobalConfigStore))!; + Assert.Equal("a", (await global.LoadAsync(default)).ActiveProfile); + } + + [Fact] + public async Task Create_ReturnsExit2_WhenAuthMissing() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + var exit = await new ProfileCreateCliCommand + { Name = "p", Auth = "ghost", Connection = "conn-a" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Create_ReturnsExit2_WhenConnectionMissing() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + var exit = await new ProfileCreateCliCommand + { Name = "p", Auth = "cred-a", Connection = "ghost" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task List_MarksActiveProfile() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "a", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + await new ProfileCreateCliCommand { Name = "b", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + exit = await new ProfileListCliCommand().RunAsync(); + + Assert.Equal(0, exit); + using var doc = JsonDocument.Parse(sw.ToString()); + var arr = doc.RootElement.EnumerateArray().ToList(); + Assert.Equal(2, arr.Count); + Assert.Single(arr, e => e.GetProperty("active").GetBoolean()); + Assert.True(arr.Single(e => e.GetProperty("id").GetString() == "a").GetProperty("active").GetBoolean()); + } + + [Fact] + public async Task Show_DefaultsToActiveProfile_WhenNameOmitted() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "active", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + exit = await new ProfileShowCliCommand().RunAsync(); + + Assert.Equal(0, exit); + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal("active", doc.RootElement.GetProperty("id").GetString()); + // Expanded connection + credential should be non-null so scripts don't need a second round-trip. + Assert.Equal(JsonValueKind.Object, doc.RootElement.GetProperty("connection").ValueKind); + Assert.Equal(JsonValueKind.Object, doc.RootElement.GetProperty("credential").ValueKind); + } + + [Fact] + public async Task Show_ReturnsExit2_WhenNoActiveAndNoName() + { + using var host = new CommandTestHost(); + var exit = await new ProfileShowCliCommand().RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Show_ReturnsExit2_WhenNamedProfileMissing() + { + using var host = new CommandTestHost(); + var exit = await new ProfileShowCliCommand { Name = "ghost" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Update_RebindsAuthAndConnection() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await SeedAsync(host, credId: "cred-b", connId: "conn-b"); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + + var exit = await new ProfileUpdateCliCommand + { + Name = "p", + Auth = "cred-b", + Connection = "conn-b", + Description = "updated", + }.RunAsync(); + Assert.Equal(0, exit); + + var store = (IProfileStore)host.Provider.GetService(typeof(IProfileStore))!; + var p = await store.GetAsync("p", default); + Assert.Equal("cred-b", p!.CredentialRef); + Assert.Equal("conn-b", p.ConnectionRef); + Assert.Equal("updated", p.Description); + } + + [Fact] + public async Task Update_RefusesNoOp() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + + var exit = await new ProfileUpdateCliCommand { Name = "p" }.RunAsync(); + Assert.Equal(1, exit); + } + + [Fact] + public async Task Update_ReturnsExit2_WhenProfileMissing() + { + using var host = new CommandTestHost(); + var exit = await new ProfileUpdateCliCommand { Name = "ghost", Auth = "x" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Update_ReturnsExit2_WhenNewAuthMissing() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + + var exit = await new ProfileUpdateCliCommand { Name = "p", Auth = "ghost" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Update_EmptyDescription_ClearsField() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand + { Name = "p", Auth = "cred-a", Connection = "conn-a", Description = "initial" }.RunAsync(); + + var exit = await new ProfileUpdateCliCommand { Name = "p", Description = "" }.RunAsync(); + Assert.Equal(0, exit); + + var store = (IProfileStore)host.Provider.GetService(typeof(IProfileStore))!; + var p = await store.GetAsync("p", default); + Assert.Null(p!.Description); + } + + [Fact] + public async Task Select_SetsActivePointer() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "a", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + await new ProfileCreateCliCommand { Name = "b", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + + var exit = await new ProfileSelectCliCommand { Name = "b" }.RunAsync(); + Assert.Equal(0, exit); + + var cfg = await ((IGlobalConfigStore)host.Provider.GetService(typeof(IGlobalConfigStore))!).LoadAsync(default); + Assert.Equal("b", cfg.ActiveProfile); + } + + [Fact] + public async Task Select_ReturnsExit2_WhenMissing() + { + using var host = new CommandTestHost(); + var exit = await new ProfileSelectCliCommand { Name = "ghost" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Delete_ClearsActivePointer_WhenDeletingActive() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "active", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + + var exit = await new ProfileDeleteCliCommand { Name = "active" }.RunAsync(); + Assert.Equal(0, exit); + + var cfg = await ((IGlobalConfigStore)host.Provider.GetService(typeof(IGlobalConfigStore))!).LoadAsync(default); + Assert.Null(cfg.ActiveProfile); + } + + [Fact] + public async Task Delete_WithoutCascade_KeepsDependents() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + + Assert.Equal(0, await new ProfileDeleteCliCommand { Name = "p" }.RunAsync()); + + Assert.NotNull(await ((ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!).GetAsync("cred-a", default)); + Assert.NotNull(await ((IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!).GetAsync("conn-a", default)); + } + + [Fact] + public async Task Delete_Cascade_RemovesOrphanedDependents() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + + Assert.Equal(0, await new ProfileDeleteCliCommand { Name = "p", Cascade = true }.RunAsync()); + + Assert.Null(await ((ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!).GetAsync("cred-a", default)); + Assert.Null(await ((IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!).GetAsync("conn-a", default)); + } + + [Fact] + public async Task Delete_Cascade_KeepsDependents_StillReferencedByOtherProfile() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p1", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + await new ProfileCreateCliCommand { Name = "p2", Auth = "cred-a", Connection = "conn-a" }.RunAsync(); + + Assert.Equal(0, await new ProfileDeleteCliCommand { Name = "p1", Cascade = true }.RunAsync()); + + // Dependents are still used by p2 so cascade must keep them. + Assert.NotNull(await ((ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!).GetAsync("cred-a", default)); + Assert.NotNull(await ((IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!).GetAsync("conn-a", default)); + } + + [Fact] + public async Task Delete_ReturnsExit2_WhenMissing() + { + using var host = new CommandTestHost(); + var exit = await new ProfileDeleteCliCommand { Name = "ghost" }.RunAsync(); + Assert.Equal(2, exit); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/Profile/ProfilePinUnpinTests.cs b/tests/TALXIS.CLI.Tests/Config/Commands/Profile/ProfilePinUnpinTests.cs new file mode 100644 index 0000000..929232e --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/Profile/ProfilePinUnpinTests.cs @@ -0,0 +1,152 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Profile; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Core.Storage; +using Xunit; +using ConnectionModel = TALXIS.CLI.Core.Model.Connection; + +namespace TALXIS.CLI.Tests.Config.Commands.Profile; + +[Collection("TxcServicesSerial")] +public sealed class ProfilePinUnpinTests : IDisposable +{ + private readonly string _cwd; + + public ProfilePinUnpinTests() + { + // Isolated scratch cwd so the pin file never pollutes the real repo root. + _cwd = Path.Combine(Path.GetTempPath(), "txc-pin-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_cwd); + } + + public void Dispose() + { + try { if (Directory.Exists(_cwd)) Directory.Delete(_cwd, recursive: true); } + catch { /* best effort */ } + } + + private async Task SeedAsync(CommandTestHost host) + { + var creds = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + var conns = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + await creds.UpsertAsync(new Credential + { + Id = "cred", + Kind = CredentialKind.InteractiveBrowser, + TenantId = "contoso.onmicrosoft.com", + }, default); + await conns.UpsertAsync(new ConnectionModel + { + Id = "conn", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com", + }, default); + } + + [Fact] + public async Task Pin_WritesWorkspaceFile_ForActiveProfile() + { + using var host = new CommandTestHost(currentDirectory: _cwd); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "active", Auth = "cred", Connection = "conn" }.RunAsync(); + + var exit = await new ProfilePinCliCommand().RunAsync(); + Assert.Equal(0, exit); + + var file = Path.Combine(_cwd, WorkspaceDiscovery.DirectoryName, WorkspaceDiscovery.FileName); + Assert.True(File.Exists(file)); + var wc = await JsonFile.ReadOrDefaultAsync(file, default); + Assert.Equal("active", wc.DefaultProfile); + } + + [Fact] + public async Task Pin_WithName_PinsSpecificProfile() + { + using var host = new CommandTestHost(currentDirectory: _cwd); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "a", Auth = "cred", Connection = "conn" }.RunAsync(); + await new ProfileCreateCliCommand { Name = "b", Auth = "cred", Connection = "conn" }.RunAsync(); + + Assert.Equal(0, await new ProfilePinCliCommand { Name = "b" }.RunAsync()); + + var file = Path.Combine(_cwd, WorkspaceDiscovery.DirectoryName, WorkspaceDiscovery.FileName); + var wc = await JsonFile.ReadOrDefaultAsync(file, default); + Assert.Equal("b", wc.DefaultProfile); + } + + [Fact] + public async Task Pin_ReturnsExit2_WhenNoActiveAndNoName() + { + using var host = new CommandTestHost(currentDirectory: _cwd); + var exit = await new ProfilePinCliCommand().RunAsync(); + Assert.Equal(2, exit); + Assert.False(File.Exists(Path.Combine(_cwd, WorkspaceDiscovery.DirectoryName, WorkspaceDiscovery.FileName))); + } + + [Fact] + public async Task Pin_ReturnsExit2_WhenNamedProfileMissing() + { + using var host = new CommandTestHost(currentDirectory: _cwd); + var exit = await new ProfilePinCliCommand { Name = "ghost" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Pin_IsIdempotent_OverwritesExistingFile() + { + using var host = new CommandTestHost(currentDirectory: _cwd); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "a", Auth = "cred", Connection = "conn" }.RunAsync(); + await new ProfileCreateCliCommand { Name = "b", Auth = "cred", Connection = "conn" }.RunAsync(); + + Assert.Equal(0, await new ProfilePinCliCommand { Name = "a" }.RunAsync()); + Assert.Equal(0, await new ProfilePinCliCommand { Name = "b" }.RunAsync()); + + var file = Path.Combine(_cwd, WorkspaceDiscovery.DirectoryName, WorkspaceDiscovery.FileName); + var wc = await JsonFile.ReadOrDefaultAsync(file, default); + Assert.Equal("b", wc.DefaultProfile); + } + + [Fact] + public async Task Unpin_RemovesWorkspaceFile_AndEmptyDirectory() + { + using var host = new CommandTestHost(currentDirectory: _cwd); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred", Connection = "conn" }.RunAsync(); + Assert.Equal(0, await new ProfilePinCliCommand().RunAsync()); + + var exit = await new ProfileUnpinCliCommand().RunAsync(); + Assert.Equal(0, exit); + + var dir = Path.Combine(_cwd, WorkspaceDiscovery.DirectoryName); + Assert.False(Directory.Exists(dir), "empty .txc/ should be removed too"); + } + + [Fact] + public async Task Unpin_IsIdempotent_WhenNoPinExists() + { + using var host = new CommandTestHost(currentDirectory: _cwd); + var exit = await new ProfileUnpinCliCommand().RunAsync(); + Assert.Equal(0, exit); + } + + [Fact] + public async Task Unpin_KeepsSiblingFiles_InTxcDir() + { + using var host = new CommandTestHost(currentDirectory: _cwd); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred", Connection = "conn" }.RunAsync(); + Assert.Equal(0, await new ProfilePinCliCommand().RunAsync()); + + var dir = Path.Combine(_cwd, WorkspaceDiscovery.DirectoryName); + var sibling = Path.Combine(dir, "other.json"); + await File.WriteAllTextAsync(sibling, "{}"); + + Assert.Equal(0, await new ProfileUnpinCliCommand().RunAsync()); + + // Directory must survive because it still has other content. + Assert.True(Directory.Exists(dir)); + Assert.True(File.Exists(sibling)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/Profile/ProfileValidateTests.cs b/tests/TALXIS.CLI.Tests/Config/Commands/Profile/ProfileValidateTests.cs new file mode 100644 index 0000000..38c627a --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/Profile/ProfileValidateTests.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Profile; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core; +using Xunit; +using ConnectionModel = TALXIS.CLI.Core.Model.Connection; + +namespace TALXIS.CLI.Tests.Config.Commands.Profile; + +[Collection("TxcServicesSerial")] +public sealed class ProfileValidateTests +{ + private static async Task SeedAsync(CommandTestHost host) + { + var creds = (ICredentialStore)host.Provider.GetService(typeof(ICredentialStore))!; + var conns = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + await creds.UpsertAsync(new Credential + { + Id = "cred", + Kind = CredentialKind.InteractiveBrowser, + TenantId = "contoso.onmicrosoft.com", + }, default); + await conns.UpsertAsync(new ConnectionModel + { + Id = "conn", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com", + }, default); + } + + [Fact] + public async Task Validate_ActiveProfile_ReturnsZero_AndInvokesProvider_LiveByDefault() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred", Connection = "conn" }.RunAsync(); + + var exit = await new ProfileValidateCliCommand().RunAsync(); + Assert.Equal(0, exit); + Assert.Equal(1, host.Provider_Dataverse.Calls); + Assert.Equal(ValidationMode.Live, host.Provider_Dataverse.LastMode); + } + + [Fact] + public async Task Validate_WithSkipLive_PassesStructuralMode() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred", Connection = "conn" }.RunAsync(); + + var exit = await new ProfileValidateCliCommand { SkipLive = true }.RunAsync(); + Assert.Equal(0, exit); + Assert.Equal(ValidationMode.Structural, host.Provider_Dataverse.LastMode); + } + + [Fact] + public async Task Validate_EmitsJsonWithProfileAndStatus() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred", Connection = "conn" }.RunAsync(); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) { exit = await new ProfileValidateCliCommand { SkipLive = true }.RunAsync(); } + Assert.Equal(0, exit); + + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal("p", doc.RootElement.GetProperty("profile").GetString()); + Assert.Equal("conn", doc.RootElement.GetProperty("connection").GetString()); + Assert.Equal("cred", doc.RootElement.GetProperty("credential").GetString()); + Assert.Equal("dataverse", doc.RootElement.GetProperty("provider").GetString()); + Assert.Equal("structural", doc.RootElement.GetProperty("mode").GetString()); + Assert.Equal("ok", doc.RootElement.GetProperty("status").GetString()); + } + + [Fact] + public async Task Validate_ReturnsExit2_WhenNoActiveAndNoName() + { + using var host = new CommandTestHost(); + var exit = await new ProfileValidateCliCommand().RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Validate_ReturnsExit2_WhenNamedProfileMissing() + { + using var host = new CommandTestHost(); + var exit = await new ProfileValidateCliCommand { Name = "ghost" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Validate_ReturnsExit2_WhenConnectionMissing() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred", Connection = "conn" }.RunAsync(); + + // Delete the connection directly to simulate an orphan reference. + var connStore = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + await connStore.DeleteAsync("conn", default); + + var exit = await new ProfileValidateCliCommand { Name = "p" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Validate_ReturnsExit1_WhenProviderThrows() + { + using var host = new CommandTestHost(); + host.Provider_Dataverse.Behavior = (_, _, _) => throw new InvalidOperationException("WhoAmI failed: 401"); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "p", Auth = "cred", Connection = "conn" }.RunAsync(); + + var exit = await new ProfileValidateCliCommand().RunAsync(); + Assert.Equal(1, exit); + } + + [Fact] + public async Task Validate_Named_RespectsArgumentOverActive() + { + using var host = new CommandTestHost(); + await SeedAsync(host); + await new ProfileCreateCliCommand { Name = "a", Auth = "cred", Connection = "conn" }.RunAsync(); + await new ProfileCreateCliCommand { Name = "b", Auth = "cred", Connection = "conn" }.RunAsync(); + + var sw = new StringWriter(); + using (OutputWriter.RedirectTo(sw)) { Assert.Equal(0, await new ProfileValidateCliCommand { Name = "b", SkipLive = true }.RunAsync()); } + + using var doc = JsonDocument.Parse(sw.ToString()); + Assert.Equal("b", doc.RootElement.GetProperty("profile").GetString()); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/Setting/SettingCommandsTests.cs b/tests/TALXIS.CLI.Tests/Config/Commands/Setting/SettingCommandsTests.cs new file mode 100644 index 0000000..731110e --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/Setting/SettingCommandsTests.cs @@ -0,0 +1,147 @@ +using System.IO; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Features.Config.Setting; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Commands.Setting; + +[Collection("TxcServicesSerial")] +public sealed class SettingCommandsTests +{ + [Fact] + public async Task Set_Persists_LogLevel() + { + using var host = new CommandTestHost(); + + var exit = await new SettingSetCliCommand { Key = "log.level", Value = "Debug" }.RunAsync(); + Assert.Equal(0, exit); + + var store = TxcServices.Get(); + var cfg = await store.LoadAsync(default); + Assert.Equal("debug", cfg.Log.Level); + } + + [Fact] + public async Task Set_Persists_LogFormat_AndIsCaseInsensitive() + { + using var host = new CommandTestHost(); + + var exit = await new SettingSetCliCommand { Key = "LOG.FORMAT", Value = "JSON" }.RunAsync(); + Assert.Equal(0, exit); + + var cfg = await TxcServices.Get().LoadAsync(default); + Assert.Equal("json", cfg.Log.Format); + } + + [Theory] + [InlineData("true", true)] + [InlineData("True", true)] + [InlineData("1", true)] + [InlineData("yes", true)] + [InlineData("on", true)] + [InlineData("false", false)] + [InlineData("0", false)] + [InlineData("no", false)] + [InlineData("off", false)] + public async Task Set_Persists_TelemetryEnabled(string value, bool expected) + { + using var host = new CommandTestHost(); + + var exit = await new SettingSetCliCommand { Key = "telemetry.enabled", Value = value }.RunAsync(); + Assert.Equal(0, exit); + + var cfg = await TxcServices.Get().LoadAsync(default); + Assert.Equal(expected, cfg.Telemetry.Enabled); + } + + [Fact] + public async Task Set_ReturnsExit2_ForUnknownKey() + { + using var host = new CommandTestHost(); + + var exit = await new SettingSetCliCommand { Key = "bogus.key", Value = "x" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Set_ReturnsExit2_ForInvalidEnumValue() + { + using var host = new CommandTestHost(); + + var exit = await new SettingSetCliCommand { Key = "log.level", Value = "shout" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Set_ReturnsExit2_ForInvalidBoolValue() + { + using var host = new CommandTestHost(); + + var exit = await new SettingSetCliCommand { Key = "telemetry.enabled", Value = "maybe" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task Get_PrintsDefaultValue_WhenUnset() + { + using var host = new CommandTestHost(); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + { + exit = await new SettingGetCliCommand { Key = "log.level" }.RunAsync(); + } + + Assert.Equal(0, exit); + Assert.Equal("information", sw.ToString().Trim()); + } + + [Fact] + public async Task Get_PrintsUpdatedValue_AfterSet() + { + using var host = new CommandTestHost(); + + await new SettingSetCliCommand { Key = "log.format", Value = "json" }.RunAsync(); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + { + exit = await new SettingGetCliCommand { Key = "log.format" }.RunAsync(); + } + + Assert.Equal(0, exit); + Assert.Equal("json", sw.ToString().Trim()); + } + + [Fact] + public async Task Get_ReturnsExit2_ForUnknownKey() + { + using var host = new CommandTestHost(); + + var exit = await new SettingGetCliCommand { Key = "bogus" }.RunAsync(); + Assert.Equal(2, exit); + } + + [Fact] + public async Task List_EmitsJson_ForAllKnownKeys() + { + using var host = new CommandTestHost(); + + var sw = new StringWriter(); + int exit; + using (OutputWriter.RedirectTo(sw)) + { + exit = await new SettingListCliCommand().RunAsync(); + } + + Assert.Equal(0, exit); + var output = sw.ToString(); + Assert.Contains("log.level", output); + Assert.Contains("log.format", output); + Assert.Contains("telemetry.enabled", output); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Commands/TxcServicesSerialCollection.cs b/tests/TALXIS.CLI.Tests/Config/Commands/TxcServicesSerialCollection.cs new file mode 100644 index 0000000..1c61fe3 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Commands/TxcServicesSerialCollection.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Commands; + +/// +/// TxcServices is process-global; tests that touch it must run serially. +/// Apply [Collection("TxcServicesSerial")] to every test class +/// that instantiates . +/// +[CollectionDefinition("TxcServicesSerial", DisableParallelization = true)] +public sealed class TxcServicesSerialCollection +{ +} diff --git a/tests/TALXIS.CLI.Tests/Config/Headless/HeadlessAuthRequiredExceptionTests.cs b/tests/TALXIS.CLI.Tests/Config/Headless/HeadlessAuthRequiredExceptionTests.cs new file mode 100644 index 0000000..b706dbb --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Headless/HeadlessAuthRequiredExceptionTests.cs @@ -0,0 +1,86 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Model; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Headless; + +public sealed class HeadlessAuthRequiredExceptionTests +{ + private sealed class StubDetector : IHeadlessDetector + { + public bool IsHeadless { get; init; } + public string? Reason { get; init; } + } + + [Fact] + public void PermittedKinds_ContainsExactlySpecCanonicalSet() + { + var expected = new HashSet + { + CredentialKind.ClientSecret, + CredentialKind.ClientCertificate, + CredentialKind.ManagedIdentity, + CredentialKind.WorkloadIdentityFederation, + CredentialKind.AzureCli, + CredentialKind.Pat, + }; + Assert.True(expected.SetEquals(HeadlessAuthRequiredException.PermittedHeadlessKinds)); + } + + [Fact] + public void Message_IncludesAttemptedKindInKebab_AndReason() + { + var ex = new HeadlessAuthRequiredException(CredentialKind.InteractiveBrowser, "CI=true"); + Assert.Contains("interactive-browser", ex.Message); + Assert.Contains("CI=true", ex.Message); + } + + [Fact] + public void Message_ListsAllPermittedKindsInKebab() + { + var ex = new HeadlessAuthRequiredException(CredentialKind.DeviceCode, "stdin and stdout are redirected"); + foreach (var kind in new[] + { + "client-secret", "client-certificate", "managed-identity", + "workload-identity-federation", "azure-cli", "pat", + }) + { + Assert.Contains(kind, ex.Message); + } + } + + [Fact] + public void EnsureKindAllowed_NoThrow_WhenInteractive() + { + var detector = new StubDetector { IsHeadless = false }; + detector.EnsureKindAllowed(CredentialKind.InteractiveBrowser); + detector.EnsureKindAllowed(CredentialKind.DeviceCode); + } + + [Fact] + public void EnsureKindAllowed_NoThrow_WhenHeadlessAndKindPermitted() + { + var detector = new StubDetector { IsHeadless = true, Reason = "CI=true" }; + detector.EnsureKindAllowed(CredentialKind.ClientSecret); + detector.EnsureKindAllowed(CredentialKind.ManagedIdentity); + } + + [Fact] + public void EnsureKindAllowed_Throws_WhenHeadlessAndKindForbidden() + { + var detector = new StubDetector { IsHeadless = true, Reason = "CI=true" }; + var ex = Assert.Throws(() => + detector.EnsureKindAllowed(CredentialKind.InteractiveBrowser)); + Assert.Equal(CredentialKind.InteractiveBrowser, ex.AttemptedKind); + Assert.Equal("CI=true", ex.HeadlessReason); + } + + [Fact] + public void EnsureKindAllowed_Throws_ForDeviceCode_InHeadless() + { + var detector = new StubDetector { IsHeadless = true, Reason = "TXC_NON_INTERACTIVE=1" }; + Assert.Throws(() => + detector.EnsureKindAllowed(CredentialKind.DeviceCode)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Headless/HeadlessDetectorTests.cs b/tests/TALXIS.CLI.Tests/Config/Headless/HeadlessDetectorTests.cs new file mode 100644 index 0000000..39436b0 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Headless/HeadlessDetectorTests.cs @@ -0,0 +1,71 @@ +using TALXIS.CLI.Core.Headless; +using TALXIS.CLI.Core.Resolution; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Headless; + +public class HeadlessDetectorTests +{ + [Fact] + public void InteractiveByDefault() + { + var det = new HeadlessDetector(new FakeProbe(false, false), new FakeEnv()); + Assert.False(det.IsHeadless); + Assert.Null(det.Reason); + } + + [Fact] + public void StdinAndStdoutRedirectedMarksHeadless() + { + var det = new HeadlessDetector(new FakeProbe(true, true), new FakeEnv()); + Assert.True(det.IsHeadless); + Assert.Contains("redirected", det.Reason); + } + + [Fact] + public void OnlyStdinRedirectedStaysInteractive() + { + var det = new HeadlessDetector(new FakeProbe(true, false), new FakeEnv()); + Assert.False(det.IsHeadless); + } + + [Theory] + [InlineData("TXC_NON_INTERACTIVE", "1")] + [InlineData("TXC_NON_INTERACTIVE", "true")] + [InlineData("CI", "true")] + [InlineData("GITHUB_ACTIONS", "true")] + [InlineData("TF_BUILD", "True")] + public void TruthyEnvVarForcesHeadless(string envName, string value) + { + var det = new HeadlessDetector(new FakeProbe(false, false), + new FakeEnv((envName, value))); + Assert.True(det.IsHeadless); + Assert.Contains(envName, det.Reason); + } + + [Fact] + public void FalsyEnvVarDoesNotForceHeadless() + { + var det = new HeadlessDetector(new FakeProbe(false, false), + new FakeEnv(("CI", "false"))); + Assert.False(det.IsHeadless); + } + + private sealed class FakeProbe : IConsoleRedirectionProbe + { + public FakeProbe(bool input, bool output) { IsInputRedirected = input; IsOutputRedirected = output; } + public bool IsInputRedirected { get; } + public bool IsOutputRedirected { get; } + } + + private sealed class FakeEnv : IEnvironmentReader + { + private readonly Dictionary _map; + public FakeEnv(params (string Key, string Value)[] entries) + { + _map = entries.ToDictionary(e => e.Key, e => e.Value, StringComparer.Ordinal); + } + public string? Get(string name) => _map.TryGetValue(name, out var v) ? v : null; + public string GetCurrentDirectory() => Directory.GetCurrentDirectory(); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/AuthorityChallengeResolverTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/AuthorityChallengeResolverTests.cs new file mode 100644 index 0000000..2a715d2 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/AuthorityChallengeResolverTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Net.Http.Headers; +using TALXIS.CLI.Platform.Dataverse.Authority; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Providers.Dataverse; + +public sealed class AuthorityChallengeResolverTests +{ + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(responder(request)); + } + + [Fact] + public async Task GetAuthorityAsync_ParsesAuthorizationUri_FromChallenge() + { + var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + response.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue( + "Bearer", + "authorization_uri=\"https://login.microsoftonline.com/contoso.onmicrosoft.com\", resource_id=\"https://contoso.crm.dynamics.com\"")); + + using var http = new HttpClient(new StubHandler(_ => response)); + var resolver = new AuthorityChallengeResolver(http); + + var authority = await resolver.GetAuthorityAsync(new Uri("https://contoso.crm.dynamics.com/"), default); + Assert.Equal("https://login.microsoftonline.com/contoso.onmicrosoft.com", authority.AbsoluteUri); + } + + [Fact] + public async Task GetAuthorityAsync_Throws_WhenStatusNot401() + { + using var http = new HttpClient(new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var resolver = new AuthorityChallengeResolver(http); + await Assert.ThrowsAsync(() => + resolver.GetAuthorityAsync(new Uri("https://contoso.crm.dynamics.com/"), default)); + } + + [Fact] + public async Task GetAuthorityAsync_Throws_WhenChallengeMissingAuthorizationUri() + { + var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + response.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", "realm=\"dataverse\"")); + using var http = new HttpClient(new StubHandler(_ => response)); + var resolver = new AuthorityChallengeResolver(http); + await Assert.ThrowsAsync(() => + resolver.GetAuthorityAsync(new Uri("https://contoso.crm.dynamics.com/"), default)); + } + + [Fact] + public void TryParseAuthorizationUri_AcceptsUnquotedValue() + { + var header = new AuthenticationHeaderValue( + "Bearer", + "authorization_uri=https://login.microsoftonline.com/tenant, extra=1"); + Assert.True(AuthorityChallengeResolver.TryParseAuthorizationUri(header, out var uri)); + Assert.Equal("https://login.microsoftonline.com/tenant", uri.AbsoluteUri); + } + + [Fact] + public void TryParseAuthorizationUri_RejectsNonBearerScheme() + { + var header = new AuthenticationHeaderValue( + "Basic", + "authorization_uri=\"https://login.microsoftonline.com/tenant\""); + Assert.False(AuthorityChallengeResolver.TryParseAuthorizationUri(header, out _)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseCloudMapTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseCloudMapTests.cs new file mode 100644 index 0000000..eeb1e80 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseCloudMapTests.cs @@ -0,0 +1,52 @@ +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.Dataverse.Authority; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Providers.Dataverse; + +public sealed class DataverseCloudMapTests +{ + [Theory] + [InlineData(CloudInstance.Public, "https://login.microsoftonline.com")] + [InlineData(CloudInstance.Gcc, "https://login.microsoftonline.com")] + [InlineData(CloudInstance.GccHigh, "https://login.microsoftonline.us")] + [InlineData(CloudInstance.Dod, "https://login.microsoftonline.us")] + [InlineData(CloudInstance.China, "https://login.partner.microsoftonline.cn")] + public void GetAuthorityHost_MatchesPacMapping(CloudInstance cloud, string expected) + { + Assert.Equal(expected, DataverseCloudMap.GetAuthorityHost(cloud)); + } + + [Fact] + public void BuildAuthorityUri_UsesOrganizations_WhenTenantMissing() + { + var uri = DataverseCloudMap.BuildAuthorityUri(CloudInstance.Public, null); + Assert.Equal("https://login.microsoftonline.com/organizations", uri.AbsoluteUri); + } + + [Fact] + public void BuildAuthorityUri_UsesTenant_WhenProvided() + { + var uri = DataverseCloudMap.BuildAuthorityUri(CloudInstance.GccHigh, "contoso.onmicrosoft.us"); + Assert.Equal("https://login.microsoftonline.us/contoso.onmicrosoft.us", uri.AbsoluteUri); + } + + [Theory] + [InlineData("https://contoso.crm.dynamics.com/", CloudInstance.Public)] + [InlineData("https://contoso.crm9.dynamics.com/", CloudInstance.Gcc)] + [InlineData("https://contoso.crm.microsoftdynamics.us/", CloudInstance.GccHigh)] + [InlineData("https://contoso.crm.dynamics.us/", CloudInstance.GccHigh)] + [InlineData("https://contoso.crm.appsplatform.us/", CloudInstance.Dod)] + [InlineData("https://contoso.crm.dynamics.cn/", CloudInstance.China)] + public void TryInferFromEnvironmentUrl_MatchesKnownSuffixes(string url, CloudInstance expected) + { + var inferred = DataverseCloudMap.TryInferFromEnvironmentUrl(new Uri(url)); + Assert.Equal(expected, inferred); + } + + [Fact] + public void TryInferFromEnvironmentUrl_ReturnsNull_ForUnknownHost() + { + Assert.Null(DataverseCloudMap.TryInferFromEnvironmentUrl(new Uri("https://example.com/"))); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseConnectionProviderTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseConnectionProviderTests.cs new file mode 100644 index 0000000..5bfbf2a --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseConnectionProviderTests.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Logging.Abstractions; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Msal; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Providers.Dataverse; + +public sealed class DataverseConnectionProviderTests +{ + private sealed class StubLiveChecker : IDataverseLiveChecker + { + public Task CheckAsync(Connection connection, Credential credential, CancellationToken ct) => + Task.FromResult(new DataverseLiveCheckResult(Guid.Empty, Guid.Empty, Guid.Empty)); + } + + private static DataverseConnectionProvider NewProvider(IDataverseLiveChecker? live = null) => + new(new DataverseMsalClientFactory(), + live ?? new StubLiveChecker(), + NullLogger.Instance); + + private static Connection ValidConnection(string id = "dev") => new() + { + Id = id, + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com/", + }; + + [Fact] + public void ProviderKind_IsDataverse() => + Assert.Equal(ProviderKind.Dataverse, NewProvider().ProviderKind); + + [Fact] + public void SupportedCredentialKinds_ExcludesPat() + { + var provider = NewProvider(); + Assert.DoesNotContain(CredentialKind.Pat, provider.SupportedCredentialKinds); + Assert.Contains(CredentialKind.ClientSecret, provider.SupportedCredentialKinds); + Assert.Contains(CredentialKind.InteractiveBrowser, provider.SupportedCredentialKinds); + Assert.Contains(CredentialKind.WorkloadIdentityFederation, provider.SupportedCredentialKinds); + } + + [Fact] + public async Task ValidateAsync_Succeeds_ForWellFormedInteractive() + { + var provider = NewProvider(); + var credential = new Credential { Id = "u", Kind = CredentialKind.InteractiveBrowser }; + await provider.ValidateAsync(ValidConnection(), credential, ValidationMode.Structural, default); + } + + [Fact] + public async Task ValidateAsync_Throws_WhenProviderMismatches() + { + var provider = NewProvider(); + var connection = new Connection + { + Id = "azure", + Provider = ProviderKind.Azure, + EnvironmentUrl = "https://contoso.crm.dynamics.com/", + }; + var credential = new Credential { Id = "u", Kind = CredentialKind.InteractiveBrowser }; + await Assert.ThrowsAsync(() => + provider.ValidateAsync(connection, credential, ValidationMode.Structural, default)); + } + + [Fact] + public async Task ValidateAsync_Throws_WhenEnvironmentUrlMissing() + { + var provider = NewProvider(); + var connection = ValidConnection(); + connection.EnvironmentUrl = null; + var credential = new Credential { Id = "u", Kind = CredentialKind.InteractiveBrowser }; + await Assert.ThrowsAsync(() => + provider.ValidateAsync(connection, credential, ValidationMode.Structural, default)); + } + + [Fact] + public async Task ValidateAsync_Throws_WhenEnvironmentUrlRelative() + { + var provider = NewProvider(); + var connection = ValidConnection(); + connection.EnvironmentUrl = "/api/data/v9.2"; + var credential = new Credential { Id = "u", Kind = CredentialKind.InteractiveBrowser }; + await Assert.ThrowsAsync(() => + provider.ValidateAsync(connection, credential, ValidationMode.Structural, default)); + } + + [Fact] + public async Task ValidateAsync_Throws_ForUnsupportedKind() + { + var provider = NewProvider(); + var credential = new Credential { Id = "u", Kind = CredentialKind.Pat }; + await Assert.ThrowsAsync(() => + provider.ValidateAsync(ValidConnection(), credential, ValidationMode.Structural, default)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseMsalClientFactoryTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseMsalClientFactoryTests.cs new file mode 100644 index 0000000..9339ebb --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseMsalClientFactoryTests.cs @@ -0,0 +1,124 @@ +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Platform.Dataverse.Msal; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Providers.Dataverse; + +public sealed class DataverseMsalClientFactoryTests +{ + [Fact] + public void BuildPublicClient_UsesPacClientIdAndLocalhostRedirect() + { + var factory = new DataverseMsalClientFactory(); + var connection = new Connection + { + Id = "dev", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com/", + }; + + var client = factory.BuildPublicClient(connection); + Assert.Equal(DataverseMsalClientFactory.PublicClientId, client.AppConfig.ClientId); + Assert.Equal("https://login.microsoftonline.com/organizations/", client.Authority); + } + + [Fact] + public void BuildPublicClient_InfersSovereignCloud_FromHostSuffix() + { + var factory = new DataverseMsalClientFactory(); + var connection = new Connection + { + Id = "dod", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.appsplatform.us/", + }; + + var client = factory.BuildPublicClient(connection); + Assert.StartsWith("https://login.microsoftonline.us/", client.Authority); + } + + [Fact] + public void BuildPublicClient_UsesExplicitTenant_WhenProvided() + { + var factory = new DataverseMsalClientFactory(); + var connection = new Connection + { + Id = "dev", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com/", + TenantId = "11111111-1111-1111-1111-111111111111", + }; + + var client = factory.BuildPublicClient(connection); + Assert.EndsWith("/11111111-1111-1111-1111-111111111111/", client.Authority); + } + + [Fact] + public void BuildConfidentialClient_RequiresApplicationId() + { + var factory = new DataverseMsalClientFactory(); + var connection = new Connection + { + Id = "dev", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com/", + }; + var credential = new Credential + { + Id = "spn", + Kind = CredentialKind.ClientSecret, + TenantId = "tenant", + }; + + Assert.Throws(() => + factory.BuildConfidentialClient(connection, credential, new ConfidentialClientMaterial { ClientSecret = "x" })); + } + + [Fact] + public void BuildConfidentialClient_RequiresSomeMaterial() + { + var factory = new DataverseMsalClientFactory(); + var connection = new Connection + { + Id = "dev", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com/", + }; + var credential = new Credential + { + Id = "spn", + Kind = CredentialKind.ClientSecret, + TenantId = "tenant", + ApplicationId = "22222222-2222-2222-2222-222222222222", + }; + + Assert.Throws(() => + factory.BuildConfidentialClient(connection, credential, new ConfidentialClientMaterial())); + } + + [Fact] + public void BuildConfidentialClient_WithSecret_PinsClientIdAndAuthority() + { + var factory = new DataverseMsalClientFactory(); + var connection = new Connection + { + Id = "dev", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com/", + TenantId = "11111111-1111-1111-1111-111111111111", + }; + var credential = new Credential + { + Id = "spn", + Kind = CredentialKind.ClientSecret, + ApplicationId = "22222222-2222-2222-2222-222222222222", + }; + + var client = factory.BuildConfidentialClient( + connection, + credential, + new ConfidentialClientMaterial { ClientSecret = "super-secret" }); + Assert.Equal("22222222-2222-2222-2222-222222222222", client.AppConfig.ClientId); + Assert.EndsWith("/11111111-1111-1111-1111-111111111111/", client.Authority); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseScopeTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseScopeTests.cs new file mode 100644 index 0000000..06e517f --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/DataverseScopeTests.cs @@ -0,0 +1,28 @@ +using TALXIS.CLI.Platform.Dataverse.Scopes; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Providers.Dataverse; + +public sealed class DataverseScopeTests +{ + [Fact] + public void BuildDefault_UsesDoubleSlash_ForDataverseAudience() + { + var scope = DataverseScope.BuildDefault(new Uri("https://contoso.crm.dynamics.com/")); + Assert.Equal("https://contoso.crm.dynamics.com//.default", scope); + } + + [Fact] + public void BuildDefault_StripsPathAndQuery() + { + var scope = DataverseScope.BuildDefault(new Uri("https://contoso.crm.dynamics.com/api/data/v9.2?foo=bar")); + Assert.Equal("https://contoso.crm.dynamics.com//.default", scope); + } + + [Fact] + public void BuildDefault_PreservesNonStandardPort() + { + var scope = DataverseScope.BuildDefault(new Uri("https://dv.local:8443/")); + Assert.Equal("https://dv.local:8443//.default", scope); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/FederatedAssertionCallbacksTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/FederatedAssertionCallbacksTests.cs new file mode 100644 index 0000000..10d6249 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Providers/Dataverse/FederatedAssertionCallbacksTests.cs @@ -0,0 +1,121 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using TALXIS.CLI.Platform.Dataverse.Msal; +using TALXIS.CLI.Core.Resolution; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Providers.Dataverse; + +public sealed class FederatedAssertionCallbacksTests +{ + private sealed class DictEnv(Dictionary map) : IEnvironmentReader + { + public string? Get(string name) => map.TryGetValue(name, out var v) ? v : null; + public string GetCurrentDirectory() => Directory.GetCurrentDirectory(); + } + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + public HttpRequestMessage? Last; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Last = request; + return Task.FromResult(responder(request)); + } + } + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + [Fact] + public async Task ForAzureDevOps_PostsWithBearer_AndReturnsOidcToken() + { + var env = new DictEnv(new() + { + [FederatedAssertionCallbacks.AdoRequestUrlVar] = "https://ado.example/oidc", + [FederatedAssertionCallbacks.AdoRequestTokenVar] = "sys-access", + }); + var handler = new StubHandler(_ => Json("{\"oidcToken\":\"jwt-abc\"}")); + using var http = new HttpClient(handler); + + var jwt = await FederatedAssertionCallbacks.ForAzureDevOps(env, http)(default); + + Assert.Equal("jwt-abc", jwt); + Assert.Equal(HttpMethod.Post, handler.Last!.Method); + Assert.Equal(new AuthenticationHeaderValue("Bearer", "sys-access"), handler.Last.Headers.Authorization); + } + + [Fact] + public async Task ForAzureDevOps_AcceptsLegacyPacEnvNames() + { + var env = new DictEnv(new() + { + [FederatedAssertionCallbacks.AdoRequestUrlVarLegacy] = "https://ado.example/oidc", + [FederatedAssertionCallbacks.AdoRequestTokenVarLegacy] = "sys-access", + }); + using var http = new HttpClient(new StubHandler(_ => Json("{\"oidcToken\":\"pac-jwt\"}"))); + var jwt = await FederatedAssertionCallbacks.ForAzureDevOps(env, http)(default); + Assert.Equal("pac-jwt", jwt); + } + + [Fact] + public async Task ForGitHubActions_AppendsAudienceQuery_AndReturnsValue() + { + var env = new DictEnv(new() + { + [FederatedAssertionCallbacks.GitHubRequestUrlVar] = "https://gh.example/oidc?arg=1", + [FederatedAssertionCallbacks.GitHubRequestTokenVar] = "gh-bearer", + }); + var handler = new StubHandler(_ => Json("{\"value\":\"gh-jwt\",\"count\":100}")); + using var http = new HttpClient(handler); + + var jwt = await FederatedAssertionCallbacks.ForGitHubActions(env, http)(default); + + Assert.Equal("gh-jwt", jwt); + Assert.Equal(HttpMethod.Get, handler.Last!.Method); + Assert.Contains("audience=api%3A%2F%2FAzureADTokenExchange", handler.Last.RequestUri!.Query); + Assert.StartsWith("?arg=1&audience=", handler.Last.RequestUri.Query); + } + + [Fact] + public async Task ForFederatedTokenFile_ReadsJwtFromFile() + { + var path = Path.Combine(Path.GetTempPath(), $"txc-fed-{Guid.NewGuid():N}.jwt"); + await File.WriteAllTextAsync(path, " file-jwt\n"); + try + { + var env = new DictEnv(new() { [FederatedAssertionCallbacks.FederatedTokenFileVar] = path }); + var jwt = await FederatedAssertionCallbacks.ForFederatedTokenFile(env)(default); + Assert.Equal("file-jwt", jwt); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void AutoSelect_PrefersAdo_OverGitHub_OverFile() + { + var env = new DictEnv(new() + { + [FederatedAssertionCallbacks.AdoRequestUrlVar] = "https://ado.example/oidc", + [FederatedAssertionCallbacks.AdoRequestTokenVar] = "x", + [FederatedAssertionCallbacks.GitHubRequestUrlVar] = "https://gh.example/oidc", + [FederatedAssertionCallbacks.GitHubRequestTokenVar] = "y", + [FederatedAssertionCallbacks.FederatedTokenFileVar] = "/tmp/x", + }); + var cb = FederatedAssertionCallbacks.AutoSelect(env); + Assert.NotNull(cb); + } + + [Fact] + public void AutoSelect_Throws_WhenNoSourceConfigured() + { + var env = new DictEnv(new()); + Assert.Throws(() => FederatedAssertionCallbacks.AutoSelect(env)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Resolution/PrecedenceTests.cs b/tests/TALXIS.CLI.Tests/Config/Resolution/PrecedenceTests.cs new file mode 100644 index 0000000..1e9beee --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Resolution/PrecedenceTests.cs @@ -0,0 +1,140 @@ +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Core.Storage; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Resolution; + +public class PrecedenceTests +{ + [Fact] + public async Task CommandLineBeatsEverything() + { + using var dir = new TempConfigDir(); + var (resolver, env) = await SetupAsync(dir, env: new() { + ["TXC_PROFILE"] = "from-env", + }, globalActive: "from-global", workspaceDefault: "from-workspace", + profiles: new[] { "from-flag", "from-env", "from-workspace", "from-global" }); + + var ctx = await resolver.ResolveAsync("from-flag", CancellationToken.None); + Assert.Equal("from-flag", ctx.Profile!.Id); + Assert.Equal(ResolutionSource.CommandLine, ctx.Source); + } + + [Fact] + public async Task EnvVarBeatsWorkspaceAndGlobal() + { + using var dir = new TempConfigDir(); + var (resolver, _) = await SetupAsync(dir, env: new() { + ["TXC_PROFILE"] = "from-env", + }, globalActive: "from-global", workspaceDefault: "from-workspace", + profiles: new[] { "from-env", "from-workspace", "from-global" }); + + var ctx = await resolver.ResolveAsync(null, CancellationToken.None); + Assert.Equal("from-env", ctx.Profile!.Id); + Assert.Equal(ResolutionSource.EnvironmentVariable, ctx.Source); + } + + [Fact] + public async Task WorkspaceBeatsGlobal() + { + using var dir = new TempConfigDir(); + var (resolver, _) = await SetupAsync(dir, env: new(), + globalActive: "from-global", workspaceDefault: "from-workspace", + profiles: new[] { "from-workspace", "from-global" }); + + var ctx = await resolver.ResolveAsync(null, CancellationToken.None); + Assert.Equal("from-workspace", ctx.Profile!.Id); + Assert.Equal(ResolutionSource.Workspace, ctx.Source); + } + + [Fact] + public async Task GlobalUsedWhenNoOverrides() + { + using var dir = new TempConfigDir(); + var (resolver, _) = await SetupAsync(dir, env: new(), + globalActive: "from-global", workspaceDefault: null, + profiles: new[] { "from-global" }); + + var ctx = await resolver.ResolveAsync(null, CancellationToken.None); + Assert.Equal("from-global", ctx.Profile!.Id); + Assert.Equal(ResolutionSource.Global, ctx.Source); + } + + [Fact] + public async Task ThrowsWhenNothingResolvable() + { + using var dir = new TempConfigDir(); + var (resolver, _) = await SetupAsync(dir, env: new(), + globalActive: null, workspaceDefault: null, profiles: Array.Empty()); + + await Assert.ThrowsAsync( + () => resolver.ResolveAsync(null, CancellationToken.None)); + } + + [Fact] + public async Task ThrowsWhenReferencedProfileMissing() + { + using var dir = new TempConfigDir(); + var (resolver, _) = await SetupAsync(dir, env: new(), + globalActive: "ghost", workspaceDefault: null, profiles: Array.Empty()); + + var ex = await Assert.ThrowsAsync( + () => resolver.ResolveAsync(null, CancellationToken.None)); + Assert.Contains("ghost", ex.Message); + } + + private static async Task<(ConfigurationResolver resolver, FakeEnv env)> SetupAsync( + TempConfigDir dir, + Dictionary env, + string? globalActive, + string? workspaceDefault, + string[] profiles) + { + var profileStore = new ProfileStore(dir.Paths); + var connectionStore = new ConnectionStore(dir.Paths); + var credentialStore = new CredentialStore(dir.Paths); + var globalStore = new GlobalConfigStore(dir.Paths); + + // Create a single connection + credential so every profile resolves. + await connectionStore.UpsertAsync(new Connection { Id = "conn", Provider = ProviderKind.Dataverse, EnvironmentUrl = "https://x/" }, CancellationToken.None); + await credentialStore.UpsertAsync(new Credential { Id = "cred", Kind = CredentialKind.InteractiveBrowser }, CancellationToken.None); + + foreach (var id in profiles) + await profileStore.UpsertAsync(new Profile { Id = id, ConnectionRef = "conn", CredentialRef = "cred" }, CancellationToken.None); + + if (globalActive is not null) + await globalStore.SaveAsync(new GlobalConfig { ActiveProfile = globalActive }, CancellationToken.None); + + string cwd; + if (workspaceDefault is not null) + { + cwd = Directory.CreateTempSubdirectory("txc-ws-test-").FullName; + var txc = Path.Combine(cwd, ".txc"); + Directory.CreateDirectory(txc); + await File.WriteAllTextAsync(Path.Combine(txc, "workspace.json"), + $"{{ \"defaultProfile\": \"{workspaceDefault}\" }}"); + } + else + { + // Use a directory guaranteed not to have a .txc/workspace.json up-chain. + cwd = Directory.CreateTempSubdirectory("txc-ws-empty-").FullName; + } + + var fakeEnv = new FakeEnv(env, cwd); + var resolver = new ConfigurationResolver( + profileStore, connectionStore, credentialStore, globalStore, + new WorkspaceDiscovery(), fakeEnv); + return (resolver, fakeEnv); + } + + private sealed class FakeEnv : IEnvironmentReader + { + private readonly Dictionary _env; + private readonly string _cwd; + public FakeEnv(Dictionary env, string cwd) { _env = env; _cwd = cwd; } + public string? Get(string name) => _env.TryGetValue(name, out var v) ? v : null; + public string GetCurrentDirectory() => _cwd; + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Resolution/WorkspaceDiscoveryTests.cs b/tests/TALXIS.CLI.Tests/Config/Resolution/WorkspaceDiscoveryTests.cs new file mode 100644 index 0000000..3329db7 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Resolution/WorkspaceDiscoveryTests.cs @@ -0,0 +1,57 @@ +using TALXIS.CLI.Core.Resolution; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Resolution; + +public class WorkspaceDiscoveryTests +{ + [Fact] + public async Task ReturnsNullWhenNoWorkspaceFoundUpward() + { + var tmp = Directory.CreateTempSubdirectory("txc-ws-none-").FullName; + try + { + var discovery = new WorkspaceDiscovery(); + var result = await discovery.DiscoverAsync(tmp, CancellationToken.None); + Assert.Null(result); + } + finally { Directory.Delete(tmp, true); } + } + + [Fact] + public async Task FirstHitWinsFromNestedDirectory() + { + var root = Directory.CreateTempSubdirectory("txc-ws-outer-").FullName; + try + { + // outer workspace + var outerTxc = Path.Combine(root, ".txc"); + Directory.CreateDirectory(outerTxc); + await File.WriteAllTextAsync(Path.Combine(outerTxc, "workspace.json"), + "{ \"defaultProfile\": \"outer\" }"); + + // inner workspace, nested a few dirs down + var innerDir = Path.Combine(root, "sub", "deep"); + Directory.CreateDirectory(innerDir); + var innerTxc = Path.Combine(innerDir, ".txc"); + Directory.CreateDirectory(innerTxc); + await File.WriteAllTextAsync(Path.Combine(innerTxc, "workspace.json"), + "{ \"defaultProfile\": \"inner\" }"); + + var discovery = new WorkspaceDiscovery(); + + var fromDeep = await discovery.DiscoverAsync(innerDir, CancellationToken.None); + Assert.NotNull(fromDeep); + Assert.Equal("inner", fromDeep!.Config.DefaultProfile); + Assert.Equal(innerDir, fromDeep.WorkspaceRoot); + + // Walking from a directory that only has the outer workspace reaches outer. + var outerSibling = Path.Combine(root, "sibling"); + Directory.CreateDirectory(outerSibling); + var fromOuter = await discovery.DiscoverAsync(outerSibling, CancellationToken.None); + Assert.NotNull(fromOuter); + Assert.Equal("outer", fromOuter!.Config.DefaultProfile); + } + finally { Directory.Delete(root, true); } + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Storage/ConnectionStoreRoundtripTests.cs b/tests/TALXIS.CLI.Tests/Config/Storage/ConnectionStoreRoundtripTests.cs new file mode 100644 index 0000000..e641063 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Storage/ConnectionStoreRoundtripTests.cs @@ -0,0 +1,59 @@ +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Storage; + +public class ConnectionStoreRoundtripTests +{ + [Fact] + public async Task UpsertPersistsAllProviderFields() + { + using var dir = new TempConfigDir(); + var store = new ConnectionStore(dir.Paths); + var c = new Connection + { + Id = "customer-a-dev", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm4.dynamics.com/", + Cloud = CloudInstance.Public, + TenantId = "tenant-1", + }; + await store.UpsertAsync(c, CancellationToken.None); + + // Open a new store instance to force reload from disk. + var got = await new ConnectionStore(dir.Paths).GetAsync("customer-a-dev", CancellationToken.None); + Assert.NotNull(got); + Assert.Equal(ProviderKind.Dataverse, got!.Provider); + Assert.Equal("https://contoso.crm4.dynamics.com/", got.EnvironmentUrl); + Assert.Equal(CloudInstance.Public, got.Cloud); + Assert.Equal("tenant-1", got.TenantId); + } + + [Fact] + public async Task UnknownFieldsAreRetainedAcrossRoundTrip() + { + using var dir = new TempConfigDir(); + // Write a JSON doc that includes a forward-compat field the model doesn't know. + var json = """ + { + "connections": [ + { "id": "future", "provider": "dataverse", "environmentUrl": "https://x/", "someFutureField": "preserve-me" } + ] + } + """; + await File.WriteAllTextAsync(dir.Paths.ConnectionsFile, json); + + var store = new ConnectionStore(dir.Paths); + var loaded = await store.GetAsync("future", CancellationToken.None); + Assert.NotNull(loaded); + Assert.NotNull(loaded!.ExtraFields); + Assert.True(loaded.ExtraFields!.ContainsKey("someFutureField")); + + // Upsert back out and confirm the unknown field survives. + await store.UpsertAsync(loaded, CancellationToken.None); + var rewritten = await File.ReadAllTextAsync(dir.Paths.ConnectionsFile); + Assert.Contains("someFutureField", rewritten); + Assert.Contains("preserve-me", rewritten); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Storage/CredentialStoreRoundtripTests.cs b/tests/TALXIS.CLI.Tests/Config/Storage/CredentialStoreRoundtripTests.cs new file mode 100644 index 0000000..6ce6eb8 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Storage/CredentialStoreRoundtripTests.cs @@ -0,0 +1,46 @@ +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Storage; + +public class CredentialStoreRoundtripTests +{ + [Fact] + public async Task SecretRefIsSerialisedAsUriString() + { + using var dir = new TempConfigDir(); + var store = new CredentialStore(dir.Paths); + var cred = new Credential + { + Id = "ci-spn", + Kind = CredentialKind.ClientSecret, + TenantId = "tenant-1", + ApplicationId = "app-1", + SecretRef = SecretRef.Create("ci-spn", "client-secret"), + }; + await store.UpsertAsync(cred, CancellationToken.None); + + var raw = await File.ReadAllTextAsync(dir.Paths.CredentialsFile); + Assert.Contains("vault://com.talxis.txc/ci-spn/client-secret", raw); + Assert.Contains("client-secret", raw); // kebab-case enum + + var loaded = await new CredentialStore(dir.Paths).GetAsync("ci-spn", CancellationToken.None); + Assert.NotNull(loaded); + Assert.Equal(CredentialKind.ClientSecret, loaded!.Kind); + Assert.NotNull(loaded.SecretRef); + Assert.Equal("ci-spn", loaded.SecretRef!.CredentialId); + Assert.Equal("client-secret", loaded.SecretRef.Slot); + } + + [Fact] + public async Task NullSecretRefIsOmittedFromJson() + { + using var dir = new TempConfigDir(); + var store = new CredentialStore(dir.Paths); + await store.UpsertAsync(new Credential { Id = "interactive", Kind = CredentialKind.InteractiveBrowser }, CancellationToken.None); + + var raw = await File.ReadAllTextAsync(dir.Paths.CredentialsFile); + Assert.DoesNotContain("secretRef", raw); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Storage/GlobalConfigStoreRoundtripTests.cs b/tests/TALXIS.CLI.Tests/Config/Storage/GlobalConfigStoreRoundtripTests.cs new file mode 100644 index 0000000..5909ae6 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Storage/GlobalConfigStoreRoundtripTests.cs @@ -0,0 +1,27 @@ +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Storage; + +public class GlobalConfigStoreRoundtripTests +{ + [Fact] + public async Task LoadReturnsDefaultsWhenFileMissing() + { + using var dir = new TempConfigDir(); + var store = new GlobalConfigStore(dir.Paths); + var cfg = await store.LoadAsync(CancellationToken.None); + Assert.Null(cfg.ActiveProfile); + } + + [Fact] + public async Task SaveThenLoadRoundTripsActiveProfile() + { + using var dir = new TempConfigDir(); + var store = new GlobalConfigStore(dir.Paths); + await store.SaveAsync(new GlobalConfig { ActiveProfile = "customer-a-dev" }, CancellationToken.None); + var loaded = await new GlobalConfigStore(dir.Paths).LoadAsync(CancellationToken.None); + Assert.Equal("customer-a-dev", loaded.ActiveProfile); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Storage/ProfileStoreRoundtripTests.cs b/tests/TALXIS.CLI.Tests/Config/Storage/ProfileStoreRoundtripTests.cs new file mode 100644 index 0000000..43bf69b --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Storage/ProfileStoreRoundtripTests.cs @@ -0,0 +1,68 @@ +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Storage; + +public class ProfileStoreRoundtripTests +{ + [Fact] + public async Task UpsertThenGetReturnsSameProfile() + { + using var dir = new TempConfigDir(); + var store = new ProfileStore(dir.Paths); + var p = new Profile { Id = "customer-a-dev", ConnectionRef = "c1", CredentialRef = "cred1", Description = "test" }; + + await store.UpsertAsync(p, CancellationToken.None); + var got = await store.GetAsync("customer-a-dev", CancellationToken.None); + + Assert.NotNull(got); + Assert.Equal("c1", got!.ConnectionRef); + Assert.Equal("cred1", got.CredentialRef); + Assert.Equal("test", got.Description); + } + + [Fact] + public async Task GetIsCaseInsensitive() + { + using var dir = new TempConfigDir(); + var store = new ProfileStore(dir.Paths); + await store.UpsertAsync(new Profile { Id = "Foo", ConnectionRef = "c", CredentialRef = "k" }, CancellationToken.None); + + Assert.NotNull(await store.GetAsync("foo", CancellationToken.None)); + Assert.NotNull(await store.GetAsync("FOO", CancellationToken.None)); + } + + [Fact] + public async Task UpsertReplacesExistingEntryByIdCaseInsensitive() + { + using var dir = new TempConfigDir(); + var store = new ProfileStore(dir.Paths); + await store.UpsertAsync(new Profile { Id = "x", ConnectionRef = "c1", CredentialRef = "k1" }, CancellationToken.None); + await store.UpsertAsync(new Profile { Id = "X", ConnectionRef = "c2", CredentialRef = "k2" }, CancellationToken.None); + + var all = await store.ListAsync(CancellationToken.None); + Assert.Single(all); + Assert.Equal("c2", all[0].ConnectionRef); + } + + [Fact] + public async Task DeleteReturnsTrueOnlyWhenPresent() + { + using var dir = new TempConfigDir(); + var store = new ProfileStore(dir.Paths); + await store.UpsertAsync(new Profile { Id = "a", ConnectionRef = "c", CredentialRef = "k" }, CancellationToken.None); + + Assert.True(await store.DeleteAsync("A", CancellationToken.None)); + Assert.False(await store.DeleteAsync("a", CancellationToken.None)); + Assert.Empty(await store.ListAsync(CancellationToken.None)); + } + + [Fact] + public async Task ListReturnsEmptyWhenFileMissing() + { + using var dir = new TempConfigDir(); + var store = new ProfileStore(dir.Paths); + Assert.Empty(await store.ListAsync(CancellationToken.None)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Storage/SecretRefTests.cs b/tests/TALXIS.CLI.Tests/Config/Storage/SecretRefTests.cs new file mode 100644 index 0000000..89c5174 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Storage/SecretRefTests.cs @@ -0,0 +1,46 @@ +using TALXIS.CLI.Core.Model; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Storage; + +public class SecretRefTests +{ + [Fact] + public void FormatsCanonicalUri() + { + var r = SecretRef.Create("ci-spn", "client-secret"); + Assert.Equal("vault://com.talxis.txc/ci-spn/client-secret", r.Uri); + Assert.Equal(r.Uri, r.ToString()); + } + + [Theory] + [InlineData("vault://com.talxis.txc/ci-spn/client-secret", "ci-spn", "client-secret")] + [InlineData("vault://com.talxis.txc/cred.1/pat", "cred.1", "pat")] + public void ParsesValidUris(string input, string expectedId, string expectedSlot) + { + var r = SecretRef.Parse(input); + Assert.Equal(expectedId, r.CredentialId); + Assert.Equal(expectedSlot, r.Slot); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData("https://example/x/y")] + [InlineData("vault://other-service/x/y")] + [InlineData("vault://com.talxis.txc/only-one-segment")] + [InlineData("vault://com.talxis.txc/a/b/c")] + public void RejectsInvalidUris(string? input) + { + Assert.False(SecretRef.TryParse(input, out _)); + Assert.Throws(() => SecretRef.Parse(input ?? "")); + } + + [Fact] + public void RoundTripsThroughParse() + { + var original = SecretRef.Create("my-cred", "certificate-password"); + var parsed = SecretRef.Parse(original.Uri); + Assert.Equal(original, parsed); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/TempConfigDir.cs b/tests/TALXIS.CLI.Tests/Config/TempConfigDir.cs new file mode 100644 index 0000000..68b81d1 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/TempConfigDir.cs @@ -0,0 +1,20 @@ +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Tests.Config; + +internal sealed class TempConfigDir : IDisposable +{ + public string Path { get; } + public ConfigPaths Paths { get; } + + public TempConfigDir() + { + Path = Directory.CreateTempSubdirectory("txc-test-").FullName; + Paths = new ConfigPaths(Path); + } + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best effort */ } + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Vault/MsalBackedCredentialVaultTests.cs b/tests/TALXIS.CLI.Tests/Config/Vault/MsalBackedCredentialVaultTests.cs new file mode 100644 index 0000000..b7dd7a1 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Vault/MsalBackedCredentialVaultTests.cs @@ -0,0 +1,179 @@ +using Microsoft.Extensions.Logging.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using TALXIS.CLI.Core.Vault; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Vault; + +/// +/// Exercises against the plaintext-file +/// path so tests do not touch the host Keychain/DPAPI/libsecret. The protected +/// path is covered by MSAL Extensions' own test suite; we only validate our +/// JSON-dictionary shape and DI semantics here. +/// +public sealed class MsalBackedCredentialVaultTests +{ + private static VaultOptions PlaintextSecrets() => new() + { + CacheFileName = "txc.secrets.v1.dat", + KeychainAccount = "secrets", + LinuxKeyringLabel = "TALXIS CLI secrets", + LinuxCacheKind = "TXC_Secret_Vault", + UsePlaintextFallback = true, + PlaintextReason = "test", + }; + + private static async Task NewVaultAsync(ConfigPaths paths) + => await MsalBackedCredentialVault.CreateForTestingAsync( + PlaintextSecrets(), + paths, + NullLogger.Instance); + + [Fact] + public async Task GetSecret_ReturnsNull_WhenEmpty() + { + using var dir = new TempConfigDir(); + var vault = await NewVaultAsync(dir.Paths); + + var value = await vault.GetSecretAsync(SecretRef.Create("cred1", "client-secret"), CancellationToken.None); + + Assert.Null(value); + } + + [Fact] + public async Task SetSecret_ThenGetSecret_Roundtrips() + { + using var dir = new TempConfigDir(); + var vault = await NewVaultAsync(dir.Paths); + var @ref = SecretRef.Create("cred1", "client-secret"); + + await vault.SetSecretAsync(@ref, "s3cr3t", CancellationToken.None); + var value = await vault.GetSecretAsync(@ref, CancellationToken.None); + + Assert.Equal("s3cr3t", value); + } + + [Fact] + public async Task SetSecret_OverwritesPriorValue() + { + using var dir = new TempConfigDir(); + var vault = await NewVaultAsync(dir.Paths); + var @ref = SecretRef.Create("cred1", "pat"); + + await vault.SetSecretAsync(@ref, "v1", CancellationToken.None); + await vault.SetSecretAsync(@ref, "v2", CancellationToken.None); + + Assert.Equal("v2", await vault.GetSecretAsync(@ref, CancellationToken.None)); + } + + [Fact] + public async Task MultipleSecrets_AreIsolatedByCredentialIdAndSlot() + { + using var dir = new TempConfigDir(); + var vault = await NewVaultAsync(dir.Paths); + + await vault.SetSecretAsync(SecretRef.Create("cred-a", "client-secret"), "A-cs", CancellationToken.None); + await vault.SetSecretAsync(SecretRef.Create("cred-a", "pat"), "A-pat", CancellationToken.None); + await vault.SetSecretAsync(SecretRef.Create("cred-b", "client-secret"), "B-cs", CancellationToken.None); + + Assert.Equal("A-cs", await vault.GetSecretAsync(SecretRef.Create("cred-a", "client-secret"), CancellationToken.None)); + Assert.Equal("A-pat", await vault.GetSecretAsync(SecretRef.Create("cred-a", "pat"), CancellationToken.None)); + Assert.Equal("B-cs", await vault.GetSecretAsync(SecretRef.Create("cred-b", "client-secret"), CancellationToken.None)); + } + + [Fact] + public async Task DeleteSecret_ReturnsTrue_AndRemovesEntry() + { + using var dir = new TempConfigDir(); + var vault = await NewVaultAsync(dir.Paths); + var @ref = SecretRef.Create("cred1", "client-secret"); + await vault.SetSecretAsync(@ref, "s3cr3t", CancellationToken.None); + + var removed = await vault.DeleteSecretAsync(@ref, CancellationToken.None); + + Assert.True(removed); + Assert.Null(await vault.GetSecretAsync(@ref, CancellationToken.None)); + } + + [Fact] + public async Task DeleteSecret_ReturnsFalse_WhenAbsent() + { + using var dir = new TempConfigDir(); + var vault = await NewVaultAsync(dir.Paths); + + var removed = await vault.DeleteSecretAsync(SecretRef.Create("missing", "pat"), CancellationToken.None); + + Assert.False(removed); + } + + [Fact] + public async Task SetSecret_WritesJsonDictionary_KeyedByCredentialIdAndSlot() + { + using var dir = new TempConfigDir(); + var vault = await NewVaultAsync(dir.Paths); + + await vault.SetSecretAsync(SecretRef.Create("cred1", "client-secret"), "hello", CancellationToken.None); + + var fallbackPath = Path.Combine(dir.Paths.AuthDirectory, "txc.secrets.v1.fallback.dat"); + Assert.True(File.Exists(fallbackPath), $"Expected fallback file at {fallbackPath}"); + var json = await File.ReadAllTextAsync(fallbackPath); + using var doc = System.Text.Json.JsonDocument.Parse(json); + Assert.Equal("hello", doc.RootElement.GetProperty("cred1::client-secret").GetString()); + } + + [Fact] + public async Task Vault_UsesDistinctFallbackFilename() + { + using var dir = new TempConfigDir(); + var vault = await NewVaultAsync(dir.Paths); + + await vault.SetSecretAsync(SecretRef.Create("cred1", "pat"), "x", CancellationToken.None); + + var protectedPath = Path.Combine(dir.Paths.AuthDirectory, "txc.secrets.v1.dat"); + var fallbackPath = Path.Combine(dir.Paths.AuthDirectory, "txc.secrets.v1.fallback.dat"); + + Assert.False(File.Exists(protectedPath), "Protected filename must not be used when in plaintext mode."); + Assert.True(File.Exists(fallbackPath)); + } + + [Fact] + public async Task Helper_IsStableAcrossCalls_OnSameVaultInstance() + { + using var dir = new TempConfigDir(); + var vault = await NewVaultAsync(dir.Paths); + + await vault.SetSecretAsync(SecretRef.Create("c", "pat"), "1", CancellationToken.None); + var helperAfterSet = vault.CacheHelper; + await vault.GetSecretAsync(SecretRef.Create("c", "pat"), CancellationToken.None); + var helperAfterGet = vault.CacheHelper; + + Assert.Same(helperAfterSet, helperAfterGet); + } + + [Fact] + public async Task SetSecret_RejectsEmptyCredentialIdOrSlot() + { + using var dir = new TempConfigDir(); + var vault = await NewVaultAsync(dir.Paths); + + await Assert.ThrowsAsync(() => + vault.SetSecretAsync(new SecretRef { CredentialId = "", Slot = "pat" }, "x", CancellationToken.None)); + await Assert.ThrowsAsync(() => + vault.SetSecretAsync(new SecretRef { CredentialId = "c", Slot = "" }, "x", CancellationToken.None)); + } + + [Fact] + public async Task TxcConfigDir_IsolatesVaultWrites() + { + using var dirA = new TempConfigDir(); + using var dirB = new TempConfigDir(); + var vaultA = await NewVaultAsync(dirA.Paths); + var vaultB = await NewVaultAsync(dirB.Paths); + + await vaultA.SetSecretAsync(SecretRef.Create("c", "pat"), "A-value", CancellationToken.None); + + Assert.Equal("A-value", await vaultA.GetSecretAsync(SecretRef.Create("c", "pat"), CancellationToken.None)); + Assert.Null(await vaultB.GetSecretAsync(SecretRef.Create("c", "pat"), CancellationToken.None)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Vault/VaultOptionsTests.cs b/tests/TALXIS.CLI.Tests/Config/Vault/VaultOptionsTests.cs new file mode 100644 index 0000000..c9b0270 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Vault/VaultOptionsTests.cs @@ -0,0 +1,87 @@ +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Core.Vault; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Vault; + +public sealed class VaultOptionsTests +{ + private sealed class StubEnv : IEnvironmentReader + { + private readonly Dictionary _vars; + public StubEnv(Dictionary? vars = null) + => _vars = vars ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + public string? Get(string name) => _vars.TryGetValue(name, out var v) ? v : null; + public string GetCurrentDirectory() => Directory.GetCurrentDirectory(); + } + + [Fact] + public void Secrets_UsesLockedFilenameAndAccount() + { + var o = VaultOptions.Secrets(new StubEnv()); + Assert.Equal("txc.secrets.v1.dat", o.CacheFileName); + Assert.Equal("secrets", o.KeychainAccount); + Assert.Equal("TXC_Secret_Vault", o.LinuxCacheKind); + Assert.Equal("TALXIS CLI secrets", o.LinuxKeyringLabel); + } + + [Fact] + public void MsalTokenCache_UsesLockedFilenameAndAccount() + { + var o = VaultOptions.MsalTokenCache(new StubEnv()); + Assert.Equal("txc.msal.tokens.v1.dat", o.CacheFileName); + Assert.Equal("msal-tokens", o.KeychainAccount); + Assert.Equal("TXC_Msal_Token_Cache", o.LinuxCacheKind); + } + + [Fact] + public void FallbackCacheFileName_AppendsFallbackBeforeExtension() + { + var o = VaultOptions.Secrets(new StubEnv()); + Assert.Equal("txc.secrets.v1.fallback.dat", o.FallbackCacheFileName); + } + + [Fact] + public void Secrets_HonorsLinuxPlaintextEnvVar_OnLinux() + { + if (!OperatingSystem.IsLinux()) + return; + var env = new StubEnv(new Dictionary { [VaultOptions.LinuxPlaintextEnvVar] = "1" }); + var o = VaultOptions.Secrets(env); + Assert.True(o.UsePlaintextFallback); + Assert.Contains("TXC_PLAINTEXT_FALLBACK", o.PlaintextReason); + } + + [Fact] + public void Secrets_HonorsMacFileModeEnvVar_OnMac() + { + if (!OperatingSystem.IsMacOS()) + return; + var env = new StubEnv(new Dictionary { [VaultOptions.MacFileModeEnvVar] = "file" }); + var o = VaultOptions.Secrets(env); + Assert.True(o.UsePlaintextFallback); + Assert.Contains("TXC_TOKEN_CACHE_MODE=file", o.PlaintextReason); + } + + [Fact] + public void Secrets_IgnoresEnvVars_OnWindows() + { + if (!OperatingSystem.IsWindows()) + return; + var env = new StubEnv(new Dictionary + { + [VaultOptions.LinuxPlaintextEnvVar] = "1", + [VaultOptions.MacFileModeEnvVar] = "file", + }); + var o = VaultOptions.Secrets(env); + Assert.False(o.UsePlaintextFallback); + } + + [Fact] + public void Secrets_ReturnsNotPlaintext_ByDefault() + { + var o = VaultOptions.Secrets(new StubEnv()); + Assert.False(o.UsePlaintextFallback); + Assert.Null(o.PlaintextReason); + } +} diff --git a/tests/TALXIS.CLI.Tests/Config/Vault/VaultUnavailableExceptionTests.cs b/tests/TALXIS.CLI.Tests/Config/Vault/VaultUnavailableExceptionTests.cs new file mode 100644 index 0000000..dc7dd57 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Vault/VaultUnavailableExceptionTests.cs @@ -0,0 +1,31 @@ +using TALXIS.CLI.Core.Vault; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Vault; + +public sealed class VaultUnavailableExceptionTests +{ + [Fact] + public void DefaultMessage_ContainsCanonicalRemedy() + { + var ex = new VaultUnavailableException(); + Assert.Equal(VaultUnavailableException.RemedyMessage, ex.Message); + } + + [Fact] + public void Message_MentionsLibsecretAndPlaintextOptIns() + { + var ex = new VaultUnavailableException(); + Assert.Contains("libsecret-1-0", ex.Message); + Assert.Contains("TXC_PLAINTEXT_FALLBACK", ex.Message); + Assert.Contains("--plaintext-fallback", ex.Message); + } + + [Fact] + public void Constructor_PreservesInnerException() + { + var inner = new InvalidOperationException("root cause"); + var ex = new VaultUnavailableException(inner); + Assert.Same(inner, ex.InnerException); + } +} diff --git a/tests/TALXIS.CLI.Tests/Dataverse/DataverseDateTimeTests.cs b/tests/TALXIS.CLI.Tests/Dataverse/DataverseDateTimeTests.cs index b7583df..b6b0cc0 100644 --- a/tests/TALXIS.CLI.Tests/Dataverse/DataverseDateTimeTests.cs +++ b/tests/TALXIS.CLI.Tests/Dataverse/DataverseDateTimeTests.cs @@ -1,5 +1,5 @@ using System; -using TALXIS.CLI.Dataverse; +using TALXIS.CLI.Platform.Dataverse; using Xunit; namespace TALXIS.CLI.Tests.Dataverse; diff --git a/tests/TALXIS.CLI.Tests/Environment/Deployment/DeploymentListCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Environment/Deployment/DeploymentListCliCommandTests.cs index 0ee7f76..1234b63 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Deployment/DeploymentListCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Deployment/DeploymentListCliCommandTests.cs @@ -1,7 +1,8 @@ -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment; -using TALXIS.CLI.Environment.Deployment; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Features.Environment.Deployment; +using TALXIS.CLI.Platform.Dataverse.Platforms; using Xunit; namespace TALXIS.CLI.Tests.Environment.Deployment; diff --git a/tests/TALXIS.CLI.Tests/Environment/Deployment/DeploymentShowCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Environment/Deployment/DeploymentShowCliCommandTests.cs index fc976c5..1c88096 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Deployment/DeploymentShowCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Deployment/DeploymentShowCliCommandTests.cs @@ -1,4 +1,4 @@ -using TALXIS.CLI.Environment.Deployment; +using TALXIS.CLI.Features.Environment.Deployment; using Xunit; namespace TALXIS.CLI.Tests.Environment.Deployment; diff --git a/tests/TALXIS.CLI.Tests/Environment/Package/PackageUninstallCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Environment/Package/PackageUninstallCliCommandTests.cs index 129d37f..fe7c2a1 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Package/PackageUninstallCliCommandTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Package/PackageUninstallCliCommandTests.cs @@ -1,6 +1,6 @@ -using TALXIS.CLI.Environment; -using TALXIS.CLI.Environment.Package; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Features.Environment.Package; +using TALXIS.CLI.Platform.Dataverse.Platforms; using Xunit; namespace TALXIS.CLI.Tests.Environment.Package; diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DeploymentFindingsAnalyzerTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DeploymentFindingsAnalyzerTests.cs index 394f21f..c728d5d 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DeploymentFindingsAnalyzerTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DeploymentFindingsAnalyzerTests.cs @@ -1,8 +1,9 @@ +using TALXIS.CLI.Core.Platforms.Dataverse; using System; using System.Collections.Generic; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Platform.Dataverse.Platforms; using Xunit; namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DeploymentRelativeTimeParserTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DeploymentRelativeTimeParserTests.cs index be2403a..0ff0cc5 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DeploymentRelativeTimeParserTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DeploymentRelativeTimeParserTests.cs @@ -1,6 +1,7 @@ -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Platform.Dataverse.Platforms; using Xunit; namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/PackageHistoryWriterTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/PackageHistoryWriterTests.cs index 91205b6..cb80bcd 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/PackageHistoryWriterTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/PackageHistoryWriterTests.cs @@ -1,6 +1,6 @@ -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Platform.Dataverse.Platforms; using Xunit; namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/PackageImportConfigReaderTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/PackageImportConfigReaderTests.cs index e0ed976..b9b7dbd 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/PackageImportConfigReaderTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/PackageImportConfigReaderTests.cs @@ -1,7 +1,7 @@ using System.IO.Compression; using System.Text; -using TALXIS.CLI.Environment; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Platform.Dataverse.Platforms; using Xunit; namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionHistoryMappingsTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionHistoryMappingsTests.cs index 48950d9..72966fe 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionHistoryMappingsTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionHistoryMappingsTests.cs @@ -1,6 +1,6 @@ -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Platform.Dataverse.Platforms; using Xunit; namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionHistoryReaderTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionHistoryReaderTests.cs index 2f24ad8..1d4eaf6 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionHistoryReaderTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionHistoryReaderTests.cs @@ -1,8 +1,8 @@ using System; using Microsoft.Xrm.Sdk; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Platform.Dataverse.Platforms; using Xunit; namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionImporterPathSelectionTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionImporterPathSelectionTests.cs index 8df86ca..e1be3f1 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionImporterPathSelectionTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionImporterPathSelectionTests.cs @@ -1,7 +1,8 @@ using System.Xml.Linq; -using TALXIS.CLI.Dataverse; -using TALXIS.CLI.Environment; -using TALXIS.CLI.Environment.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Core.Platforms.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Platforms; using Xunit; namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; diff --git a/tests/TALXIS.CLI.Tests/Environment/SkeletonScaffoldTests.cs b/tests/TALXIS.CLI.Tests/Environment/SkeletonScaffoldTests.cs index 4e53d27..2c163cb 100644 --- a/tests/TALXIS.CLI.Tests/Environment/SkeletonScaffoldTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/SkeletonScaffoldTests.cs @@ -2,9 +2,9 @@ using System.Reflection; using DotMake.CommandLine; using TALXIS.CLI; -using TALXIS.CLI.Environment.Deployment; -using TALXIS.CLI.Workspace; -using TALXIS.CLI.Workspace.Metamodel; +using TALXIS.CLI.Features.Environment.Deployment; +using TALXIS.CLI.Features.Workspace; +using TALXIS.CLI.Features.Workspace.Metamodel; using Xunit; namespace TALXIS.CLI.Tests.Environment; diff --git a/tests/TALXIS.CLI.Tests/Logging/LogRedactionFilterTests.cs b/tests/TALXIS.CLI.Tests/Logging/LogRedactionFilterTests.cs index 92dd5ea..51f4d67 100644 --- a/tests/TALXIS.CLI.Tests/Logging/LogRedactionFilterTests.cs +++ b/tests/TALXIS.CLI.Tests/Logging/LogRedactionFilterTests.cs @@ -94,4 +94,58 @@ public void Redact_MultipleSecrets_RedactsAll() Assert.DoesNotContain("tok1", result2); Assert.DoesNotContain("key2", result2); } + + [Fact] + public void Redact_BearerToken_Replaced() + { + var input = "Request headers: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc.def more text"; + var result = LogRedactionFilter.Redact(input); + Assert.DoesNotContain("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", result); + Assert.Contains("Bearer ***REDACTED***", result); + } + + [Fact] + public void Redact_AuthorizationHeader_ReplacedFullValue() + { + var input = "Sending: Authorization: Basic QWxhZGRpbjpvcGVuU2VzYW1l\nNext-Line: ok"; + var result = LogRedactionFilter.Redact(input); + Assert.DoesNotContain("QWxhZGRpbjpvcGVuU2VzYW1l", result); + Assert.Contains("Authorization: ***REDACTED***", result); + Assert.Contains("Next-Line: ok", result); + } + + [Fact] + public void Redact_BareJwt_Replaced() + { + var input = "Token received: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c and handled"; + var result = LogRedactionFilter.Redact(input); + Assert.DoesNotContain("SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", result); + Assert.Contains("***REDACTED***", result); + } + + [Fact] + public void Redact_ClientSecret_InConnectionString() + { + var input = "AuthType=ClientSecret;Url=https://org.crm.dynamics.com;ClientId=abc;ClientSecret=topsecret123;"; + var result = LogRedactionFilter.Redact(input); + Assert.DoesNotContain("topsecret123", result); + } + + [Fact] + public void Redact_AccessTokenKey_InConnectionString() + { + var input = "AccessToken=abcdef;Url=https://x"; + var result = LogRedactionFilter.Redact(input); + Assert.DoesNotContain("abcdef", result); + } + + [Fact] + public void Redact_AccessTokenQueryParam() + { + var input = "https://example.com/callback?access_token=hunter2&state=x"; + var result = LogRedactionFilter.Redact(input); + Assert.DoesNotContain("hunter2", result); + Assert.Contains("access_token=***REDACTED***", result); + Assert.Contains("state=x", result); + } } diff --git a/tests/TALXIS.CLI.Tests/MCP/CliCommandAdapterProfileArgumentTests.cs b/tests/TALXIS.CLI.Tests/MCP/CliCommandAdapterProfileArgumentTests.cs new file mode 100644 index 0000000..96ffc98 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/MCP/CliCommandAdapterProfileArgumentTests.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using TALXIS.CLI.Features.Environment.Solution; +using TALXIS.CLI.MCP; +using Xunit; + +namespace TALXIS.CLI.Tests.MCP; + +/// +/// Verifies that the MCP tool input-schema surface automatically exposes +/// the profile argument on every Connection-touching tool — i.e. +/// every command that derives from ProfiledCliCommand. This is +/// the per-call override contract documented in +/// src/TALXIS.CLI.MCP/README.md#per-call-profile-override: an MCP +/// client may pass { "profile": "customer-a-dev" } on a single +/// tool call, and forwards +/// it as --profile=<name>, enabling context switching +/// within one MCP session without restart. +/// +/// The test reflects the live command type so that it cannot silently +/// stop working if a future refactor moves Profile off the base +/// class or drops the [CliOption] attribute. +/// +public class CliCommandAdapterProfileArgumentTests +{ + [Fact] + public void InputSchema_ProfiledCommand_ExposesProfileArgument() + { + var adapter = new CliCommandAdapter(); + var schema = adapter.BuildInputSchema(typeof(SolutionListCliCommand)); + + // Schema shape: { "type": "object", "properties": { ... }, "required": [...] } + Assert.True(schema.TryGetProperty("properties", out var props)); + Assert.True(props.TryGetProperty("profile", out var profile), + "Connection-touching tools must expose a 'profile' property so MCP clients can override per-call."); + + Assert.Equal("string", profile.GetProperty("type").GetString()); + // Profile is optional on ProfiledCliCommand; every derived command + // inherits Required=false and must NOT appear in the required list. + // JSON Schema allows omitting "required" entirely when there are no + // required members, so treat an absent property as an empty set. + var required = schema.TryGetProperty("required", out var requiredProperty) + ? requiredProperty.EnumerateArray().Select(x => x.GetString()).ToList() + : new List(); + Assert.DoesNotContain("profile", required); + } + + [Fact] + public void BuildCliArgs_ProfileArgument_ForwardedAsFlag() + { + var adapter = new CliCommandAdapter(); + var args = new Dictionary + { + ["profile"] = JsonSerializer.SerializeToElement("customer-a-dev"), + }; + + var cliArgs = adapter.BuildCliArgs("environment_solution_list", args); + Assert.Contains("--profile", cliArgs); + Assert.Contains("customer-a-dev", cliArgs); + } +} diff --git a/tests/TALXIS.CLI.Tests/MCP/CliCommandLookupServiceTests.cs b/tests/TALXIS.CLI.Tests/MCP/CliCommandLookupServiceTests.cs new file mode 100644 index 0000000..ea4a85d --- /dev/null +++ b/tests/TALXIS.CLI.Tests/MCP/CliCommandLookupServiceTests.cs @@ -0,0 +1,74 @@ +using DotMake.CommandLine; +using TALXIS.CLI.MCP; +using TALXIS.CLI.Core; +using Xunit; + +namespace TALXIS.CLI.Tests.MCP; + +public class CliCommandLookupServiceTests +{ + private readonly CliCommandLookupService _sut = new(); + + // Minimal fake root used by tests below so they don't depend on the real command tree. + [CliCommand(Description = "Root", Children = new[] { typeof(VisibleChild), typeof(IgnoredChild) })] + private class FakeRoot { public void Run() { } } + + [CliCommand(Name = "visible", Description = "Visible leaf")] + private class VisibleChild { public void Run() { } } + + [McpIgnore] + [CliCommand(Name = "ignored", Description = "Ignored leaf")] + private class IgnoredChild { public void Run() { } } + + [CliCommand(Description = "Parent with an ignored subtree", Children = new[] { typeof(IgnoredSubChild) })] + [McpIgnore] + private class IgnoredParentWithChildren { public void Run() { } } + + [CliCommand(Name = "sub", Description = "Sub-child of ignored parent")] + private class IgnoredSubChild { public void Run() { } } + + [CliCommand(Description = "Root with ignored subtree", Children = new[] { typeof(IgnoredParentWithChildren) })] + private class FakeRootWithIgnoredSubtree { public void Run() { } } + + [Fact] + public void EnumerateAllCommands_ExcludesIgnoredLeaf() + { + var tools = _sut.EnumerateAllCommands(typeof(FakeRoot)).ToList(); + + Assert.Contains(tools, t => t.Name == "visible"); + Assert.DoesNotContain(tools, t => t.Name == "ignored"); + } + + [Fact] + public void EnumerateAllCommands_ExcludesEntireIgnoredSubtree() + { + var tools = _sut.EnumerateAllCommands(typeof(FakeRootWithIgnoredSubtree)).ToList(); + + // The ignored parent's child must not appear either. + Assert.DoesNotContain(tools, t => t.Name.Contains("sub")); + } + + [Fact] + public void EnumerateAllCommands_RealTree_ExcludesKnownHiddenCommands() + { + var sut = new CliCommandLookupService(); + var tools = sut.EnumerateAllCommands(typeof(TxcCliCommand)).Select(t => t.Name).ToList(); + + // Commands that carry [McpIgnore] must not appear. + Assert.DoesNotContain(tools, n => n.EndsWith("_start")); // transform server start + Assert.DoesNotContain("config_auth_login", tools); + Assert.DoesNotContain("config_auth_delete", tools); + Assert.DoesNotContain("config_connection_delete", tools); + Assert.DoesNotContain("config_profile_delete", tools); + Assert.DoesNotContain("config_profile_update", tools); + Assert.DoesNotContain("config_profile_validate", tools); + Assert.DoesNotContain("config_profile_pin", tools); + Assert.DoesNotContain("config_profile_unpin", tools); + + // Core tools must still be present. + Assert.Contains("config_profile_create", tools); + Assert.Contains("config_profile_select", tools); + Assert.Contains("config_auth_add-service-principal", tools); + Assert.Contains("workspace_explain", tools); + } +} diff --git a/tests/TALXIS.CLI.Tests/MCP/McpServerProtocolTests.cs b/tests/TALXIS.CLI.Tests/MCP/McpServerProtocolTests.cs index e5f84ed..b4a798c 100644 --- a/tests/TALXIS.CLI.Tests/MCP/McpServerProtocolTests.cs +++ b/tests/TALXIS.CLI.Tests/MCP/McpServerProtocolTests.cs @@ -65,7 +65,7 @@ public McpServerProtocolTests() if (toolName == "copilot-instructions") { var output = new StringWriter(); - using var redirect = TALXIS.CLI.Shared.OutputWriter.RedirectTo(output); + using var redirect = TALXIS.CLI.Core.OutputWriter.RedirectTo(output); var command = new CopilotInstructionsCliCommand(); await command.RunAsync(null!); return new CallToolResult @@ -181,13 +181,15 @@ public async Task ListTools_ShortLivedToolsDoNotHaveTaskSupport() { var tools = _registry.ListTools(); - var docsTool = tools.FirstOrDefault(t => t.Name == "docs"); - Assert.NotNull(docsTool); - Assert.Null(docsTool.Execution); - + // copilot-instructions is a short-lived MCP-only tool (no task support) var copilotTool = tools.FirstOrDefault(t => t.Name == "copilot-instructions"); Assert.NotNull(copilotTool); Assert.Null(copilotTool.Execution); + + // workspace_explain is a short-lived CLI tool (no task support) + var workspaceExplainTool = tools.FirstOrDefault(t => t.Name == "workspace_explain"); + Assert.NotNull(workspaceExplainTool); + Assert.Null(workspaceExplainTool.Execution); } public async ValueTask DisposeAsync() diff --git a/tests/TALXIS.CLI.Tests/Shared/OutputWriterTests.cs b/tests/TALXIS.CLI.Tests/Shared/OutputWriterTests.cs index ec4abc6..6feed5a 100644 --- a/tests/TALXIS.CLI.Tests/Shared/OutputWriterTests.cs +++ b/tests/TALXIS.CLI.Tests/Shared/OutputWriterTests.cs @@ -1,4 +1,4 @@ -using TALXIS.CLI.Shared; +using TALXIS.CLI.Core; using Xunit; namespace TALXIS.CLI.Tests.Shared; diff --git a/tests/TALXIS.CLI.Tests/TALXIS.CLI.Tests.csproj b/tests/TALXIS.CLI.Tests/TALXIS.CLI.Tests.csproj index 1cb3e5d..c0461db 100644 --- a/tests/TALXIS.CLI.Tests/TALXIS.CLI.Tests.csproj +++ b/tests/TALXIS.CLI.Tests/TALXIS.CLI.Tests.csproj @@ -16,10 +16,11 @@ - + - - + + +