From b6357689387c62a7a884e62bb2151234c23af4b7 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 19:35:47 -0700 Subject: [PATCH 01/10] Migrate dotnet-package-skills to Arcade build and signing Import the verified product delta above Arcade onboarding, preserve portable tool identity and functional coverage, adopt native versions and trusted release guards, and use scoped Arcade signing with artifact-only pipelines. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- LICENSE | 4 +- README.md | 28 +- dotnet-package-skills/.gitignore | 7 + dotnet-package-skills/CONTRIBUTING.md | 430 ++++ dotnet-package-skills/Directory.Build.props | 24 + .../DotnetPackageSkills.slnx | 8 + dotnet-package-skills/README.md | 616 +++++ .../src/Cli/CommandLineDiagnostics.cs | 47 + .../src/Cli/ConsoleViewport.cs | 132 + dotnet-package-skills/src/Cli/ITerminal.cs | 281 +++ .../src/Cli/InteractiveScreen.cs | 87 + .../src/Cli/InteractiveSkills.cs | 73 + dotnet-package-skills/src/Cli/OutputWriter.cs | 203 ++ dotnet-package-skills/src/Cli/PickerLayout.cs | 296 +++ dotnet-package-skills/src/Cli/SkillPicker.cs | 379 +++ dotnet-package-skills/src/Cli/TerminalText.cs | 256 ++ .../src/DotnetPackageSkills.csproj | 43 + .../src/Infrastructure/DotnetCli.cs | 20 + .../src/Infrastructure/ProcessRunner.cs | 95 + .../src/NuGet/GlobalPackagesLocator.cs | 99 + .../src/NuGet/PackageCoordinate.cs | 104 + .../src/NuGet/PackageLister.cs | 214 ++ .../src/NuGet/PackagePathResolver.cs | 101 + .../src/NuGet/TargetLocator.cs | 119 + .../src/PackageSkillsException.cs | 8 + dotnet-package-skills/src/Program.cs | 394 +++ .../src/SkillInstallService.cs | 505 ++++ .../src/Skills/BundledSkill.cs | 25 + .../src/Skills/DestinationLock.cs | 193 ++ .../src/Skills/InstallManifest.cs | 348 +++ .../src/Skills/SkillDescriptionReader.cs | 241 ++ .../src/Skills/SkillDiscovery.cs | 75 + .../src/Skills/SkillInstaller.cs | 460 ++++ .../tests/CommandLineDiagnosticsTests.cs | 179 ++ .../tests/CommandLineTests.cs | 363 +++ .../tests/DestinationLockTests.cs | 151 ++ .../tests/DotnetPackageSkills.Tests.csproj | 24 + dotnet-package-skills/tests/FakeTerminal.cs | 426 ++++ .../tests/GlobalPackagesLocatorTests.cs | 54 + .../tests/InstallManifestTests.cs | 347 +++ .../tests/InteractiveSkillsTests.cs | 186 ++ .../tests/OutputLayoutTests.cs | 199 ++ .../tests/OutputWriterTests.cs | 448 ++++ .../tests/PackageCoordinateTests.cs | 70 + .../tests/PackageListerTests.cs | 212 ++ .../tests/PackagePathResolverTests.cs | 80 + .../tests/PickerLayoutTests.cs | 259 ++ .../tests/PipelineConfigurationTests.cs | 294 +++ .../tests/PipelineInvocationTests.cs | 26 + .../tests/PipelinePackageTests.cs | 305 +++ .../tests/PipelineResultsTests.cs | 115 + .../tests/PipelineRetirementTests.cs | 18 + .../tests/PipelineSigningTests.cs | 42 + .../tests/PipelineSourceTests.cs | 64 + .../tests/PipelineTestContext.cs | 128 + .../tests/PipelineVersionTests.cs | 173 ++ .../tests/SkillDescriptionReaderTests.cs | 439 ++++ .../tests/SkillDiscoveryTests.cs | 122 + .../tests/SkillInstallServiceTests.cs | 1082 ++++++++ .../tests/SkillInstallerTests.cs | 943 +++++++ .../tests/SkillPickerTests.cs | 2194 +++++++++++++++++ .../tests/TargetLocatorTests.cs | 118 + dotnet-package-skills/tests/TempDirectory.cs | 61 + .../tests/TerminalTextTests.cs | 211 ++ eng/Build.props | 1 + eng/Signing.props | 3 + eng/Versions.props | 1 + .../dotnet-package-skills/Build.props | 27 + .../dotnet-package-skills/Build.targets | 46 + .../dotnet-package-skills/Invoke-Build.ps1 | 118 + .../Package-Validation.psm1 | 209 ++ .../dotnet-package-skills/Payload.props | 20 + eng/pipelines/dotnet-package-skills/README.md | 214 ++ .../dotnet-package-skills/Signing.props | 41 + .../dotnet-package-skills/Test-Package.ps1 | 15 + .../dotnet-package-skills/Test-Results.ps1 | 51 + .../dotnet-package-skills/Test-Retirement.ps1 | 153 ++ .../Validate-OfficialSource.ps1 | 22 + .../dotnet-package-skills/Verify-Package.ps1 | 132 + .../dotnet-package-skills/jobs-build.yml | 92 + eng/pipelines/dotnet-package-skills/stage.yml | 39 + .../dotnet-package-skills/steps-build.yml | 103 + eng/pipelines/official.yml | 30 +- eng/pipelines/pr.yml | 20 +- 84 files changed, 16546 insertions(+), 39 deletions(-) create mode 100644 dotnet-package-skills/.gitignore create mode 100644 dotnet-package-skills/CONTRIBUTING.md create mode 100644 dotnet-package-skills/Directory.Build.props create mode 100644 dotnet-package-skills/DotnetPackageSkills.slnx create mode 100644 dotnet-package-skills/README.md create mode 100644 dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs create mode 100644 dotnet-package-skills/src/Cli/ConsoleViewport.cs create mode 100644 dotnet-package-skills/src/Cli/ITerminal.cs create mode 100644 dotnet-package-skills/src/Cli/InteractiveScreen.cs create mode 100644 dotnet-package-skills/src/Cli/InteractiveSkills.cs create mode 100644 dotnet-package-skills/src/Cli/OutputWriter.cs create mode 100644 dotnet-package-skills/src/Cli/PickerLayout.cs create mode 100644 dotnet-package-skills/src/Cli/SkillPicker.cs create mode 100644 dotnet-package-skills/src/Cli/TerminalText.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills.csproj create mode 100644 dotnet-package-skills/src/Infrastructure/DotnetCli.cs create mode 100644 dotnet-package-skills/src/Infrastructure/ProcessRunner.cs create mode 100644 dotnet-package-skills/src/NuGet/GlobalPackagesLocator.cs create mode 100644 dotnet-package-skills/src/NuGet/PackageCoordinate.cs create mode 100644 dotnet-package-skills/src/NuGet/PackageLister.cs create mode 100644 dotnet-package-skills/src/NuGet/PackagePathResolver.cs create mode 100644 dotnet-package-skills/src/NuGet/TargetLocator.cs create mode 100644 dotnet-package-skills/src/PackageSkillsException.cs create mode 100644 dotnet-package-skills/src/Program.cs create mode 100644 dotnet-package-skills/src/SkillInstallService.cs create mode 100644 dotnet-package-skills/src/Skills/BundledSkill.cs create mode 100644 dotnet-package-skills/src/Skills/DestinationLock.cs create mode 100644 dotnet-package-skills/src/Skills/InstallManifest.cs create mode 100644 dotnet-package-skills/src/Skills/SkillDescriptionReader.cs create mode 100644 dotnet-package-skills/src/Skills/SkillDiscovery.cs create mode 100644 dotnet-package-skills/src/Skills/SkillInstaller.cs create mode 100644 dotnet-package-skills/tests/CommandLineDiagnosticsTests.cs create mode 100644 dotnet-package-skills/tests/CommandLineTests.cs create mode 100644 dotnet-package-skills/tests/DestinationLockTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj create mode 100644 dotnet-package-skills/tests/FakeTerminal.cs create mode 100644 dotnet-package-skills/tests/GlobalPackagesLocatorTests.cs create mode 100644 dotnet-package-skills/tests/InstallManifestTests.cs create mode 100644 dotnet-package-skills/tests/InteractiveSkillsTests.cs create mode 100644 dotnet-package-skills/tests/OutputLayoutTests.cs create mode 100644 dotnet-package-skills/tests/OutputWriterTests.cs create mode 100644 dotnet-package-skills/tests/PackageCoordinateTests.cs create mode 100644 dotnet-package-skills/tests/PackageListerTests.cs create mode 100644 dotnet-package-skills/tests/PackagePathResolverTests.cs create mode 100644 dotnet-package-skills/tests/PickerLayoutTests.cs create mode 100644 dotnet-package-skills/tests/PipelineConfigurationTests.cs create mode 100644 dotnet-package-skills/tests/PipelineInvocationTests.cs create mode 100644 dotnet-package-skills/tests/PipelinePackageTests.cs create mode 100644 dotnet-package-skills/tests/PipelineResultsTests.cs create mode 100644 dotnet-package-skills/tests/PipelineRetirementTests.cs create mode 100644 dotnet-package-skills/tests/PipelineSigningTests.cs create mode 100644 dotnet-package-skills/tests/PipelineSourceTests.cs create mode 100644 dotnet-package-skills/tests/PipelineTestContext.cs create mode 100644 dotnet-package-skills/tests/PipelineVersionTests.cs create mode 100644 dotnet-package-skills/tests/SkillDescriptionReaderTests.cs create mode 100644 dotnet-package-skills/tests/SkillDiscoveryTests.cs create mode 100644 dotnet-package-skills/tests/SkillInstallServiceTests.cs create mode 100644 dotnet-package-skills/tests/SkillInstallerTests.cs create mode 100644 dotnet-package-skills/tests/SkillPickerTests.cs create mode 100644 dotnet-package-skills/tests/TargetLocatorTests.cs create mode 100644 dotnet-package-skills/tests/TempDirectory.cs create mode 100644 dotnet-package-skills/tests/TerminalTextTests.cs create mode 100644 eng/Signing.props create mode 100644 eng/pipelines/dotnet-package-skills/Build.props create mode 100644 eng/pipelines/dotnet-package-skills/Build.targets create mode 100644 eng/pipelines/dotnet-package-skills/Invoke-Build.ps1 create mode 100644 eng/pipelines/dotnet-package-skills/Package-Validation.psm1 create mode 100644 eng/pipelines/dotnet-package-skills/Payload.props create mode 100644 eng/pipelines/dotnet-package-skills/README.md create mode 100644 eng/pipelines/dotnet-package-skills/Signing.props create mode 100644 eng/pipelines/dotnet-package-skills/Test-Package.ps1 create mode 100644 eng/pipelines/dotnet-package-skills/Test-Results.ps1 create mode 100644 eng/pipelines/dotnet-package-skills/Test-Retirement.ps1 create mode 100644 eng/pipelines/dotnet-package-skills/Validate-OfficialSource.ps1 create mode 100644 eng/pipelines/dotnet-package-skills/Verify-Package.ps1 create mode 100644 eng/pipelines/dotnet-package-skills/jobs-build.yml create mode 100644 eng/pipelines/dotnet-package-skills/stage.yml create mode 100644 eng/pipelines/dotnet-package-skills/steps-build.yml diff --git a/LICENSE b/LICENSE index b26b9d4..2bdf256 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,9 @@ -MIT License +The MIT License (MIT) Copyright (c) 2026 NuGet +All rights reserved. + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/README.md b/README.md index 541af27..18f2369 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,29 @@ # Client.Tools -This repository contains tools shipped by the NuGet Client team to help developers effectively use the latest NuGet features. \ No newline at end of file +This repository contains tools shipped by the NuGet Client team to help developers effectively use the latest NuGet features. + +## Tools + +- [dotnet-package-skills](dotnet-package-skills/README.md) copies agent skills bundled in NuGet packages into a repository's skills folder. + +## Build + +Run from the repository root on Windows, with PowerShell 7 and the root-pinned .NET SDK: + +```powershell +eng\common\build.cmd -restore -build -test -configuration Release +eng\common\build.cmd -pack -configuration Release /p:NoBuild=true +eng\common\build.cmd -sign -configuration Release /p:NETCORE_ENGINEERING_TELEMETRY=false +eng\pipelines\dotnet-package-skills\Invoke-Build.ps1 -Action Verify +``` + +Install the .NET 8 runtime into the same .NET installation that Arcade selects. The SDK supplies +the .NET 10 runtime. The build graph retains `eng\Infrastructure.proj` and includes the tool +solution. Artifacts use Arcade's `artifacts\bin`, `obj`, `log`, `TestResults`, and +`packages\Release\Shipping` layout. + +Local and public signing actions validate the signing plan without changing signatures. +Real signing requires the trusted internal main pipeline and owner-approved MicroBuild/ESRP +resources. The pipelines publish artifacts only. See the +[tool pipeline guide](eng/pipelines/dotnet-package-skills/README.md) for versions, prerequisites, +verification, and retirement. \ No newline at end of file diff --git a/dotnet-package-skills/.gitignore b/dotnet-package-skills/.gitignore new file mode 100644 index 0000000..2220015 --- /dev/null +++ b/dotnet-package-skills/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +artifacts/ +node_modules/ +*.user +.DS_Store +.agents/ diff --git a/dotnet-package-skills/CONTRIBUTING.md b/dotnet-package-skills/CONTRIBUTING.md new file mode 100644 index 0000000..c2f80f9 --- /dev/null +++ b/dotnet-package-skills/CONTRIBUTING.md @@ -0,0 +1,430 @@ +# How to contribute + +Thank you for your help. This tool is small and simple on purpose. Keep changes small and simple +too. + +## Set up your computer + +Install the .NET SDK that the repository root's `global.json` selects. Install PowerShell 7. +Install the .NET 8 runtime into the same .NET installation that Arcade uses. The SDK supplies +the .NET 10 runtime. The tool and its C# test suite target both net8.0 and net10.0. CI runs on +Windows only. Use the root SDK, Arcade imports, and public feeds. Do not add a tool-local +`global.json` or `NuGet.config`. + +```powershell +git clone https://github.com/NuGet/Client.Tools.git +Set-Location .\Client.Tools +eng\common\build.cmd -restore -build -test -configuration Release +``` + +Try your build against a real repository without installing it: + +```powershell +eng\common\dotnet.cmd artifacts\bin\DotnetPackageSkills\Release\net10.0\dotnet-package-skills.dll ` + list --target C:\path\to\YourApp.sln +``` + +Pack and verify your build without replacing a globally installed tool: + +```powershell +eng\common\build.cmd -pack -configuration Release /p:NoBuild=true +eng\common\build.cmd -sign -configuration Release /p:NETCORE_ENGINEERING_TELEMETRY=false +eng\pipelines\dotnet-package-skills\Invoke-Build.ps1 -Action Verify +``` + +## Origin + +This folder came from +[`kartheekp-ms/dotnet-package-skills` at `59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3`](https://github.com/kartheekp-ms/dotnet-package-skills/tree/59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3). +We copied the source files at that commit. We did not copy or change the original repository's +history. The source repository is unchanged. + +The source README and package metadata name the MIT license. Client.Tools keeps its own root MIT +license file. We left out the Python and Node.js terminal regression tests on purpose. The C# +picker tests and the interactive tool behavior stay the same. Build configuration and pipeline +integration live in this tool's pipeline folder, with small root build, version, signing, and +stage references. Remove these references with the tool, but keep the shared Arcade foundation. +This lets a maintainer retire the tool on its own, for example when its function moves into the +.NET SDK. + +## Layout + +``` +src/ +├── Program.cs CLI surface: commands, options, exit codes +├── SkillInstallService.cs Orchestration. This is the only file that puts the steps in order. +├── Cli/OutputWriter.cs Writes reports for people to read +├── Cli/SkillPicker.cs The --interactive picker. It shows one page per screen. +├── Cli/ITerminal.cs An interface for console access, so tests can replace the console +├── Cli/InteractiveSkills.cs Picker-only metadata and selection mapping +├── Infrastructure/ Process execution and the dotnet CLI wrapper +├── NuGet/ Target detection, package listing, cache path resolution +└── Skills/ Discovery, copying, version-change removal, the install manifest + +tests/ xunit tests. Application tests use in-process fakes. +``` + +## Rules that must stay true + +Read this list before you change this tool. Each rule protects something that is not obvious +from the code alone. Do not change a rule without a discussion first. + +**Copy files from the global packages folder. Never move them.** NuGet validates this folder +during restore. It is a content-addressable cache, and every project on the machine shares it. +If you move a file out of the cache, restore may treat the cached package as damaged. Moving a +file also removes the skill from every other repository that uses that package. + +**Use the manifest to decide what to remove. Never scan the destination folder to decide.** +`.dotnet-package-skills.json` records, for each package ID, the one installed version and the +names of the skill folders it owns. `install` acts only on those names when a package changes +version. `uninstall` acts only on those names too. Users keep their own hand-written skills in +the same folder. Deleting one of those skills by mistake is a serious failure. + +**An unreadable manifest stops every operation that needs ownership information.** Do not treat +a damaged or unreadable manifest as an empty manifest. An empty manifest means the existing +folders belong to the user. A damaged manifest means the tool does not know who owns the +folders. `install` and `uninstall` must fail before they change anything. They must keep the +file in place so the user can repair it or restore it. `list` can still run, because `list` does +not read ownership information and does not write anything. + +An existing manifest must have these parts. It must have a format `version` property, and the +value must be a whole number that this version of the tool supports. It must have a `packages` +object. Each package ID in that object must be valid under NuGet's own rule. `PackageCoordinate.IsValidId` +checks this rule, and the rule allows letters outside ASCII. Each package needs a version that is +not empty. JSON property names must be unique when you ignore letter case. Destination names +claimed by different packages must be unique when you ignore letter case. When the manifest has +a newer format version than the tool supports, the tool asks the user to update the tool. A +pre-release manifest has an `installed` array instead of a `packages` object. The tool refuses +this old format. It does not convert the file. `SetSkills` refuses any ID that the reader would +refuse. This way, the tool never writes a manifest that it cannot later read. + +In v1, use ordinary manifest files, and update them in place. The tool does not create symbolic +links. Linked or redirected manifests fall outside the v1 safety guarantees. Do not replace an +existing manifest with a new file merely to handle links. That action can change file ownership +or access rights on Unix systems. + +**The manifest is a public contract.** Reports have no machine-readable form. Scripts read the +manifest instead, and teams commit the manifest to source control. Its shape follows +`dotnet-tools.json`. It has a format `version` property and a `packages` object. The object key +is the lowercase package ID. A change that an older version of the tool would misread needs a new +format `version` number. This way, an older tool refuses the file instead of misreading it. + +Write the manifest the same way on every platform. Use UTF-8 encoding without a byte order mark. +Use LF line endings, with a final newline at the end of the file. List packages and skills in a +fixed order. Escape values the same way on `net8.0` and `net10.0`. .NET 8 has no +`JsonWriterOptions.NewLine` property, so the writer replaces its CRLF output with LF after it +serializes the file. A byte that depends on the platform creates needless differences in a pull +request. + +**When no skill is tracked, the manifest and the folder both disappear.** When the last entry +leaves the manifest, `install` and `uninstall` both delete `.dotnet-package-skills.json`. They +also delete the destination folder, but only if the folder is empty. This way, a repository where +no package ships a skill never grows a stray `.agents/skills/` folder. The tool removes the +folder only when the folder is truly empty. Hand-written skills in that folder keep it from being +deleted. + +**Descriptions are read-only text for display. They are not a requirement for installation.** +Skill discovery still identifies a skill by its folder structure alone. Only the interactive +pickers read the top-level YAML `description` property in `SKILL.md`. They read it with a +bounded frontmatter reader and a standard YAML parser. Never interpret the Markdown body of +`SKILL.md`. Never run any part of its metadata as code. Never invent a description. Never +rewrite the file. + +When a skill has no description, the picker shows an explicit placeholder text. When the +metadata is unreadable or invalid, the picker shows a visible warning, but it still shows the +skill. This rule replaces an earlier rule that forbade reading frontmatter at all. The new rule +lets users make an informed choice. Reports and the manifest never include descriptions. + +**Treat skill names from packages as untrusted input.** A skill name becomes a path segment +inside the user's repository. `SkillDiscovery.IsSafeSkillName` is the one gate that both +discovery and the manifest use. Keep this gate strict. Reject a name that ends in a dot or a +space, on every platform. Windows can resolve such a name to a different folder, or to the +destination folder itself. Before you change any file, resolve every affected skill path as a +direct child of the destination folder. When the tool removes a skill, it must not walk up the +folder tree and delete a parent folder. + +**`install` removes a skill only when its package changes to a version that drops the skill.** A +noninteractive install offers the packages that it resolved and found in the cache. A tracked +skill is removed only when all of these conditions are true. First, the tool offers its package +at a different normalized version. Second, the new version does not ship that skill. Third, no +ownership conflict protects the skill. + +When an ownership conflict does protect the skill, `install` stops instead of removing it. +Removing the skill would hand its folder name to a different package. Relabeling the skill would +record the old version's files under the new version's name, and a later run would then never +remove those old files. A package that left the project is never a reason to delete its skills. +A package reference can disappear for a moment, for example during a refactor, so `install` +reports these skills as unreferenced instead of deleting them. `uninstall --stale` removes them +when the user asks for that. + +A version that is missing from the cache never causes a removal. When a target install is +missing a resolved package, the install stops before it writes anything, including during a +preview. An incomplete cache must never look like permission to delete skills. + +**`install -i` only adds skills. It never refreshes or removes a skill.** The checklist lists +only skills that would install cleanly and that are not already tracked. Nothing starts checked. +The installer receives an empty map of offered packages, so it cannot refresh or remove anything. + +Every check that could stop the run happens before the checklist opens. The checks look for +three problems: two versions of one package, a missing package or a stale skill when you use a +target, and a named package tracked at a different version when you use `--package`. If the tool +let you add a skill next to a stale skill, the manifest would disagree with the project. If the +tool let you add a skill next to another version of the same package, one package would have two +versions in the manifest. The tool stops before the checklist opens to prevent both problems. + +**`uninstall --stale` reads package references. It does not read packages.** This command needs +a solution or project. It compares the manifest with the target's direct package references from +`dotnet list package`. It never asks where the NuGet cache is. A missing or partial cache cannot +change what counts as stale, because the command never looks at the cache. A skill is stale when +no referenced package matches both its ID and its installed version. This rule also works +correctly when a target resolves a package to two versions. `--stale` cannot combine with +`--package`. `uninstall` accepts `--target` only together with `--stale`. + +**The picker shows one page at a time. This design choice is deliberate.** A solution can +reference many packages that ship skills. `SkillPicker` renders a frame that fits inside the +window, and it redraws that frame in place. The list can never scroll off the top of the screen +unread. We designed the picker this way to prevent the worst failure: a user who approves skills +that they never saw. Page size follows the rendered height of the content, including descriptions +and keyboard help. Page size does not follow a fixed count of items. The picker itself has no +access to the file system. `InteractiveSkills` supplies package descriptions for `install`, and it +supplies installed-file descriptions for `uninstall`. + +**The tool measures the frame from its content. The window bounds the frame's size.** A skill's +name and description share one row. The text ` - ` follows the authored name directly. The +layout does not use a padded name column. Do not append package or version metadata to a name. +Do not strip the package prefix from a name. Each skill's wrapped description text lines up with +the start of the skill's own text. The description uses the rest of the row's width. The +description does not use an indent as wide as the name. Measure every line, including every +wrapped footer line, before you assign whole skill entries to pages. A user must be able to +scroll an oversized description. The tool must never truncate a description silently. The layout +must reflow when the window resizes, and it must keep the current focus and the current +selections. A checkbox change or a cursor move must never shift a page boundary. A partial last +page ends at its actual content. It never shows a run of blank rows. + +**Keyboard hints follow the style of Aspire's checklist.** The primary hint reads +`(Press to select, to accept)`. It appears below the list. Use the same +angle-bracket key notation for paging, select-all, clear-all, cancel, and description scrolling. +Start every keyboard-help line with the word `Press`. Wrap the help text instead of cutting off +any key name. Show the paging and scrolling hints only when the user can actually use paging or +scrolling. + +**Focus and checked state are the only visual cues.** The focused skill's text turns blue, +including all of its wrapped description lines. A checked item shows a blue uppercase `X` in +both pickers. Selection never changes the color of a name or a bracket. Neither picker marks what +a check mark does with a separate cue. Each picker does only one thing, so its title and its +summary state that action. The uninstall summary also states how many skills will be removed. A +terminal without color shows `>` for focus and `[X]` for a checked item. It shows no other +marker and no color legend. The tool respects the `NO_COLOR` setting. A separate status column +would take space away from the descriptions, so the tool does not use one. + +**A check mark means install in one picker. It means remove in the other picker.** Neither +picker starts with any item checked. If a user presses enter without checking anything, both +pickers change nothing. `PickerMode` carries this difference, and the difference shows only in +the summary text. The code that calls the picker supplies the title text. The uninstall list +comes from the manifest, so the picker never offers a hand-written skill for deletion. + +**The picker takes control of the terminal, so it must give control back.** `Choose` hides the +cursor. It reads Ctrl+C as ordinary input instead of letting the runtime handle it. It restores +the cursor and the input mode inside a `finally` block. This matters because of Ctrl+C: if the +runtime handled Ctrl+C directly, it would end the process in the middle of a frame, the restore +code would never run, and the user would be left typing into a terminal with no visible cursor. +The code reads Ctrl+C as a key instead, and it cancels through the same path as the `esc` key. +The code tests for the Ctrl modifier before it checks the key itself, because a plain `c` key +clears the current selection. + +**An interactive choice applies only to the ownership state that was in effect when the user +made it.** The tool rechecks its ownership snapshot before it applies an interactive choice. When +ownership changed at the same time, the tool rejects the stale choice. It does not apply that +choice to a different package's files. + +Hold the destination lock from the moment the tool loads ownership information through the +moment it writes the final manifest. Both installation and removal take part in this lock. This +way, no other run of the tool can change ownership between the check and the change. Resolve +destination aliases to one canonical path before the tool chooses which lock to use. + +**A resize while a frame is on screen makes that frame invalid.** Read the new width and the new +height together. Restart the redraw when the viewport size changes. Clear cells in place instead +of scrolling blank lines past them. If the code scrolls blank lines instead, old picker frames +build up in the terminal's history, and they can wrap incorrectly when the host resizes again. +Keep the prior scrollback content, the current focus, and the current selections. Never swallow a +rendering failure that is not related to the resize. + +The picker owns an alternate screen for as long as it runs. Clearing only the current viewport +cannot erase old rows, because the host may have already moved those rows into its normal +history. Restore the original screen and the original output mode on every managed exit. Then +write the final report on the normal screen, not on the alternate screen. Clear the alternate +viewport and move the cursor to its home position before the first frame, and do the same thing +again after every resize. When the terminal enters the alternate screen, it can keep the shell's +old cursor position. If the code then reserves space with blank lines, those lines leave a gap +above a compact checklist. Never clear the normal screen to remove that gap. + +Every render also places the cursor directly below the last line that it drew. The cursor does +not go to the bottom of the layout's maximum possible height. `SkillPickerTests` checks this +exact placement for short pages. A managed exit restores the original shell cursor position on +its own, separately, when it leaves the alternate screen. + +**The picker's own chrome uses only ASCII characters. Author-written text can use any +language.** Keep every control hint and every marker in ASCII, so that an older console encoding +does not lose or corrupt them. Display Unicode descriptions without splitting a text element in +the middle. Measure terminal cells for this purpose, not UTF-16 code units. Remove any unsafe +terminal control sequence from text that a package author supplied. Send all color output +through `ITerminal`. The picker uses UTF-8 without a byte order mark while it prompts the user. +Restore the original text encoding and the original terminal styling when the picker exits, and +also when it fails. This way, ordinary command output keeps behaving the way it always did. + +**A report written for people must not let metadata run as a terminal command.** Sanitize every +untrusted display field with `TerminalText.Sanitize`. This includes package and version metadata, +file paths, reasons for a skip, and operational errors. Framework parser diagnostics and +suggestions use a separate output path. Sanitize that path too, including any write that is split +into parts. Keep multiline error guidance readable. + +Never sanitize an argument before the tool validates it. Never save a sanitized value as a +stored value. A canonical identity, such as a package ID, must stay intact and unmodified. + +**Reports exist for people to read. The tool has no JSON report.** The manifest is the one +machine-readable record. Exit codes carry success or failure. Do not add a `--json` report back +without a new discussion of that decision. `--package` accepts several values, so the parser +hands it any unknown option that follows it. This can happen when an old script still has a +`--json` flag left in it. The validator for `--package` reports a value that starts with `-` as +an unrecognized argument. It does not report that value as a malformed package coordinate. + +**`--package` refuses a floating version and refuses a version range.** Resolving a range means +choosing one version from it. The only correct answer to that choice comes from a project's own +restore step. `PackageCoordinate.Parse` is the one gate that enforces this rule. + +**The tool scans only direct dependencies.** Application code depends on its direct package +references. It does not depend on implementation details that come in through a transitive +reference. Do not add a `--include-transitive` option. Do not parse `transitivePackages`. Both +changes need a new product discussion first. + +**The authored skill folder name becomes the destination folder name.** A package skill at +`skills/contoso.widgets-widget-usage/` lands at `/contoso.widgets-widget-usage/`. +The package ID and the package version stay in the manifest as metadata. They do not become part +of the destination path. A skill must be an immediate subfolder that contains `SKILL.md`. A lone +`skills/SKILL.md` file, with no subfolder, is not supported. This is a deliberate choice. + +**When names collide, the tool warns and skips. It never overwrites a file silently.** The tool +compares destination names without regard to letter case. Package enumeration and skill discovery +both stay deterministic, so the same package always wins a collision, every time you run the +tool. An existing untracked destination folder belongs to the user. The tool must not touch it. +No install mode can transfer an already-tracked destination from one owner package to another +package. A conflicting path that the tool skips is also protected from removal when the owner's +version changes, in the same way that it is protected from copying. A version refresh of the +same package remains allowed. The tool never relabels a protected skill under a version that does +not ship it. That case stops the install instead, as an earlier rule in this list describes. + +Keep every discovery candidate available internally until the tool knows ownership at install +time. Prefer the current owner's candidate when more than one package offers the same name. +`list` stays a discovery report that does not depend on the destination folder's current state. + +Package authors can avoid collisions by prefixing their skill folders with their lowercased +package ID. The tool does not enforce this naming convention. + +In v1, the tool matches names case-insensitively, as a logical rule. It does not reconcile two +folders that differ only in physical case on a case-sensitive file system. Package authors must +keep their folder casing stable across versions. A case-only rename, or two physical folders +that differ only in case on a case-sensitive file system, both fall outside the v1 ownership +guarantees. Do not promise safe migration for these cases. Do not add special reconciliation +logic for them without a new discussion of this scope. + +**A package filter must never broaden a destructive operation.** An explicitly blank filter +value is an error. Only a completely absent `--package` option means all packages. Interactive +uninstall and noninteractive uninstall both use the same normalized version matcher. + +**The tool allows one version of each package, or it does not install at all.** The manifest +records exactly one version for each package. The resolved packages, or the `--package` +coordinates, can sometimes include two normalized versions of one package ID. When that happens, +every install mode stops before it changes anything, and it asks the user to align the versions. +We expect repositories to use NuGet Central Package Management for this. `PackageLister.Parse` +keeps each distinct `(id, version)` pair separate, so this check can see both versions. `list` +still shows both versions too. + +**Write every error message as guidance, not as a description of the failure alone.** Throw +`PackageSkillsException` with a message that tells the user what to do next. `Program.cs` prints +this message without a stack trace. If a message would leave a user stuck with no next step, add +more words to it. A command shown inside a message must work exactly as printed, when the user +copies and pastes it. Build that command with `SkillInstallService.UninstallCommand`. This +method repeats the run's `--target` value and its non-default `--destination` value. + +## Tests + +Application unit tests run offline. They never run the `dotnet` command. Any test code that +needs the CLI goes through `IProcessRunner`. `SkillInstallServiceTests` fakes this interface. See +`FakeDotnet` in that file for the pattern to follow. Use `TempDirectory` for any test that +touches the file system. `TempDirectory` cleans up its own files afterward. + +The interactive picker goes through `ITerminal`. `FakeTerminal` drives this interface from a +scripted sequence of keys, and it reads the result back as a screen buffer. `FakeTerminal` models +a buffer instead of joining writes end to end. The picker redraws its frame in place, so joining +every write together would stack frames on top of each other. A real user sees only one page at +a time, and the test model must match that. + +### Checks for the pipeline and the package + +`PipelineVersionTests` evaluates Arcade's native MSBuild version properties for both frameworks. +It covers local, public CI, official prerelease, and stable release versions. It rejects +untrusted, automatic, missing, and malformed release inputs. Other `Pipeline*Tests` cover +package metadata and payloads, missing or failed results, trusted-source checks, and the owned +public and official templates. These tests need `pwsh` on the PATH and the restored Arcade +toolset. They use no production signing connection. + +A separate package verifier installs the produced `.nupkg` file for each target framework. It +checks the package version, the package payload, and the install, list, and uninstall behavior, +all inside temporary directories. Official builds also require a valid package signature and +valid signatures on the extracted owned and dependency DLLs. The verifier keeps the strict +SHA256 comparison between packaged owned DLLs and the explicitly listed tested loose copies. +It checks every redistributed strong-name identity and retains the original Microsoft +dependency signatures. + +Keep all pipeline logic that is specific to this tool under `eng\pipelines\dotnet-package-skills`, +at the root of the repository. See its [guide](../eng/pipelines/dotnet-package-skills/README.md) +for version numbering, official signing setup, and the checklist for retiring this tool. + +### Name unit tests + +Name each test as a sentence that describes the behavior. Do not name a test after the method +under test: + +```csharp +[Fact] +public void Install_skips_a_later_skill_when_destination_names_collide() +``` + +Every new behavior needs a test. Every bug fix needs a test that fails without the fix. The +`.slnx` preference bug shipped with a test like this, and that test is the reason the bug has +stayed fixed since then. + +## Style + +`TreatWarningsAsErrors` is on. A build must produce no warnings. Beyond that rule, match the +style of the surrounding code. Write a comment to explain why the code does something. Do not +write a comment that only restates what the code does. If you find a comment that only restates +the code, delete it. + +## Compatibility + +- The tool targets `net8.0` and `net10.0`. Do not drop `net8.0` without a discussion first. Many + teams still run the `net8.0` long-term support release. +- `dotnet list package --format json` needs SDK 7.0.200 or later. This is the lowest SDK version + that the tool can inspect, and the error message states this requirement when the installed SDK + does not meet it. +- **The tool never restores a project.** It runs `dotnet list package` without a `--no-restore` + flag, and it never runs `dotnet restore` on its own. The .NET 10 SDK restores the project during + this listing step, when the project needs it. An earlier SDK instead reports that the target + needs to be restored first. When the listing fails, the command stops. It shows what the SDK + reported, including the JSON `problems` array when the SDK provides one, so the customer can + restore the target, or fix the target, and run the command again. `PackageListerTests` checks + two things together: the exact arguments that the tool passes, and the fact that the tool never + attempts a restore. +- The output shape of `dotnet nuget locals` has changed across SDK versions. The parser reads the + key from the `global-packages:` label in that output. It does not read the key by line + position. Keep the parser written this way. + +## Pull requests + +- Make one change in each pull request. +- Make sure the root Arcade build and both framework test runs pass. +- Update the README when you change the CLI surface. +- State what you tested your change against. For example, write "Ran `install` on a solution + with 40 packages. Two of those packages ship skills." This kind of statement is worth more than + a description of the code diff. diff --git a/dotnet-package-skills/Directory.Build.props b/dotnet-package-skills/Directory.Build.props new file mode 100644 index 0000000..17cfbd5 --- /dev/null +++ b/dotnet-package-skills/Directory.Build.props @@ -0,0 +1,24 @@ + + + + 17.14.1 + 2.9.3 + 1.18.0 + 3.1.4 + + + + + + false + false + false + latest + enable + enable + true + true + true + + + diff --git a/dotnet-package-skills/DotnetPackageSkills.slnx b/dotnet-package-skills/DotnetPackageSkills.slnx new file mode 100644 index 0000000..1bdf0b9 --- /dev/null +++ b/dotnet-package-skills/DotnetPackageSkills.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md new file mode 100644 index 0000000..b91cdce --- /dev/null +++ b/dotnet-package-skills/README.md @@ -0,0 +1,616 @@ +# dotnet-package-skills + +This tool copies agent skills from inside NuGet packages into a folder that your coding agent +reads. + +## The problem + +Package authors know their own libraries best. Some package authors now ship an **agent skill** +inside the package. A skill is a set of instructions that covers the conventions, the pitfalls, +and the correct usage patterns for that library. The package stores each skill at +`skills/-/SKILL.md`. + +Restore extracts the package into the **NuGet global packages folder**. This folder is +`~/.nuget/packages` by default. It sits outside your repository, and every project on the +machine shares it. A coding agent scans a skills folder only *inside* the working repository. +Because of this, the skill sits correctly on disk, but the agent cannot see it. + +This tool closes that gap. + +``` +~/.nuget/packages/mockly/1.10.0/skills/mockly-usage/SKILL.md ← where restore puts it + ↓ +.agents/skills/mockly-usage/SKILL.md ← where your agent looks +``` + +## Install + +```bash +dotnet tool install --global dotnet-package-skills +``` + +## Use + +Run this command from your repository root: + +```bash +dotnet-package-skills install +``` + +This single command does the whole job. It finds your solution or project. It lists that +project's packages. It locates each direct dependency in the NuGet cache. It copies any bundled +skills into `.agents/skills/`. + +Run the command again after you add or upgrade a package. The command refreshes the skills of +the packages it finds. When a package moves to a new version, the command removes the skills +that version no longer ships. The command never removes a skill just because a package left the +project. Instead, it lists that skill, and you remove it later with +`dotnet-package-skills uninstall --stale`. + +### Commands + +| Command | What it does | +| --- | --- | +| `install` | Copies bundled skills into the destination. Add `--interactive` to choose which new skills to add. | +| `list` | Shows which packages ship skills. It copies nothing. | +| `uninstall` | Removes skills that this tool copied in. Add `--stale` to remove only the skills whose package the project no longer references. Add `--interactive` to pick them yourself. | + +### Choose what to read skills from + +There are three ways to tell the tool which packages to read skills from. + +```bash +dotnet-package-skills install # auto-detect solution or project +dotnet-package-skills install --target src/MyApp.slnx # a specific solution or project +dotnet-package-skills install --package Mockly@1.10.0 # exact packages, no project needed +``` + +`--package` is repeatable. It needs an **exact version**. The tool refuses `Mockly@1.*` and +`Mockly@[1.0,2.0)`. Resolving a range means picking one version from it, and the only correct +answer to "which version" comes from a project's own restore step. `--target` gives you that +answer. If the tool guessed a version instead, it could copy skills that describe a release you +do not actually reference. + +`--target` and `--package` cannot combine, because both options answer the same question. + +When you name packages explicitly, the tool touches only the packages you name. It leaves every +other installed skill alone. A target describes the project's complete set of packages. Because +of this, a target install can also tell you which installed skills belong to a package that the +project no longer references. When a package that the target resolves is missing from the NuGet +cache, `install` stops before it changes anything. Restore the project first, and then try again. + +The tool expects one version of each package. +[Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) +gives a repository that guarantee. When the target resolves a package to two versions, or when +`--package` names a package twice at two versions, `install` stops without changing anything. It +names the versions that you need to align. + +### Keep skills in step with the project + +When a package moves to a new version, `install` copies that version's skills over the old +copies. It removes any skill that the new version no longer ships. The manifest records one +version for each package, so the manifest always states which release the installed guidance +describes. + +When a package leaves the project, `install` keeps its skills and lists them: + +``` +2 installed skills belong to a package that the target no longer references: + fabrikam.testing-fakes (fabrikam.testing 1.4.0) + fabrikam.testing-fixtures (fabrikam.testing 1.4.0) +Run 'dotnet-package-skills uninstall --stale' to remove them. +``` + +The suggested command repeats the `--target` and `--destination` that you passed, so you can run +it exactly as printed. Every command that the tool's errors suggest works the same way. + +A package reference can disappear for a moment, for example halfway through a refactor. Because +of this, removing skills is always a command that you run on purpose. `uninstall --stale` removes +every **stale** skill. A stale skill is one whose package the target no longer references, or +whose package the target references at a different version. Preview this removal with +`--dry-run`, or pick among the stale skills yourself with `--interactive`: + +```bash +dotnet-package-skills uninstall --stale --dry-run +dotnet-package-skills uninstall --stale +``` + +`--stale` reads the project's package references, so it needs a solution or project. The tool +uses the one in the current directory, or the one you pass with `--target`. A skill that you +added with `install --package`, for a package outside the project, also counts as stale. + +### Choose which skills to install + +By default, `install` copies every skill that it finds. Add `--interactive` to choose which new +skills to add: + +```bash +dotnet-package-skills install --interactive # everything the project references +dotnet-package-skills install --package Mockly@1.10.0 --interactive # just one package's skills +``` + +`--interactive` combines with `--target` and `--package`. You can narrow the list to a single +package first, and then pick among the skills that package ships. Use this method when one +package bundles a dozen skills together. + +``` +Which skills should be installed? (MyApp.slnx) +Installed skills aren't listed. + +> [X] contoso.widgets-widget-usage - Correct usage patterns for the + Contoso.Widgets library, including lifetime rules and the batching API. + Use whenever code creates, configures, or disposes a Widget. + [ ] fabrikam.testing-fakes - Create fakes and verify their calls in unit + tests. + [ ] fabrikam.testing-fixtures - Share expensive setup across tests with + fixtures. + +1 of 3 selected +(Press to select, to accept) +(Press / to move, / for first/last) +(Press to select all, to clear all, // to cancel) +Blue X: selected +``` + +The checklist lists only skills that are not already installed. Nothing starts checked. When you +accept, the tool adds exactly the skills that you checked. An interactive install never refreshes +a skill and never removes a skill. Run `install` without `--interactive` to refresh a skill. Run +`uninstall` to remove a skill. When the packages ship only skills that are already installed, the +command prints `Nothing new to install.` and does not open the checklist. A skill that the tool +cannot add, for example because its name is already taken by another package or by a folder you +wrote yourself, is not listed. The report names these skills under its skipped warning instead. + +Adding a skill makes sense only when the installed skills already match the packages. Because of +this, an interactive install stops before the checklist opens, and changes nothing, in these +cases: + +- The target resolves a package to more than one version, or `--package` names a package more + than once. +- With a target, a package that the target resolves is missing from the NuGet cache. +- With a target, an installed skill is stale. Run `dotnet-package-skills uninstall --stale` + first. +- With `--package`, a named package is installed at a different version. Run + `dotnet-package-skills uninstall --package ` first. You can also run + `install --package @` without `--interactive` to move the package to its new + version. + +Each description follows the authored skill name right after ` - `. There is no padded column and +no package or version suffix. The tool keeps any package prefix in an authored name. A +continuation line flows beneath the skill's own text, using the available width, instead of +leaving a name-sized gap. The focused skill's text turns blue, including every wrapped +description line. A checked item shows a blue `X`. Every other skill name and description keeps +its normal color. The summary counts only the checked skills. There is no separate status +column. When you set `NO_COLOR`, or when your terminal has no color support, `>` marks the +focused skill and `[X]` marks each checked skill, with no other symbol beside them. + +The keyboard hints appear below the list, in the style of Aspire's +`(Press to select, to accept)` message. Every keyboard-help line starts with the +word `Press`. This applies to movement, paging, select-all, clear-all, cancel, and description +scrolling. The tool leaves out a control that would do nothing. These keys work: + +| Key | Does | +| --- | --- | +| `up` / `down` | Moves to the next or previous skill, and wraps around at either end | +| `left` / `right`, `pgup` / `pgdn` | Moves to the previous or next page | +| `home` / `end` | Jumps to the first or last skill | +| `space` | Toggles the highlighted skill | +| `a` / `c` | Selects all skills, or clears all skills, across every page | +| `ctrl+up` / `ctrl+down` | Scrolls a description when one skill is taller than a page | +| `enter` | Confirms the selection | +| `esc` / `q` / `ctrl+c` | Cancels, and changes nothing | + +The tool measures a page in rendered lines, including wrapped descriptions and keyboard hints. It +does not measure a page by a fixed number of skills. Each ordinary skill stays together on one +page. A user can scroll a description that is too long for one page, without changing the +selection. When you resize the terminal, the tool reflows the page in place. It keeps the +highlighted skill and the checked items, even during a redraw. The tool does not push old picker +frames into your scrollback. When you scroll an oversized description, the skill's own row stays +visible while its continuation lines scroll. A short list, or a partial final page, does not +leave a screenful of blank rows. A single page shows no page counter. The note under the title +gives way first when the window is too small to fit it. This way, a small window still shows the +checklist instead of refusing to open. + +The live picker uses a temporary terminal screen. It starts at the top of that screen, regardless +of where the shell's cursor was before you ran the command. A host-driven reflow cannot leave +duplicate copies of the checklist in your normal scrollback. When you accept, cancel, or hit a +handled failure, the tool restores the previous shell screen. It writes the final report there, +not next to an old checklist. + +Descriptions come from the top-level YAML `description` property in each package's `SKILL.md` +file. A missing description shows the text `No description provided.` Unreadable or malformed +metadata shows an explicit description warning, but it does not hide the skill or block its +selection. Only the interactive checklists read this metadata. Reports and the ownership +manifest never include a description. + +`--interactive` needs a terminal. Pair it with `--dry-run` to see what a selection would change, +before you commit to that change. + +### Choose what to remove + +`uninstall` also takes `--interactive`. It lists only what this tool installed. It never lists a +skill that you wrote yourself, because it reads the manifest instead of scanning the folder: + +```bash +dotnet-package-skills uninstall --interactive +``` + +``` +Which skills should be uninstalled? + +> [X] contoso.widgets-widget-testing - Testing patterns for code that uses + Contoso.Widgets. Use when writing unit or integration tests involving + widgets. + [ ] contoso.widgets-widget-usage - Correct usage patterns for the + Contoso.Widgets library, including lifetime rules and the batching API. + Use whenever code creates, configures, or disposes a Widget. + +1 of 2 selected; 1 to remove +(Press to select, to accept) +(Press / to move, / for first/last) +(Press to select all, to clear all, // to cancel) +Blue X: selected +``` + +Nothing starts checked, so a mistaken enter removes nothing. A checked row looks the same as it +does in the install checklist. Each checklist does only one thing, so its title and its summary +state what a check mark does. Narrow the list first with `--package` when you care about only one +package. Narrow it with `--stale` to see only the skills that no longer match the project: + +``` +Which skills should be uninstalled? +Only skills that don't match the target are listed. + +> [X] fabrikam.testing-fakes - Create fakes and verify their calls in unit + tests. + [ ] fabrikam.testing-fixtures - Share expensive setup across tests with + fixtures. + +1 of 2 selected; 1 to remove +``` + +Add `--dry-run` to see the outcome without it happening. Descriptions come from the installed +copies, not from the NuGet cache. A missing or damaged `SKILL.md` does not block removal of a +manifest-owned skill. Package matching ignores letter case. Both modes normalize version filters, +so `1.10` matches `1.10.0`. A blank, missing, or repeated `--package` value on `uninstall` is an +error. It never broadens the command to an unfiltered uninstall. `--stale` and `--package` cannot +combine. + +### Options + +| Option | Applies to | Description | +| --- | --- | --- | +| `-t, --target ` | install, list | Solution or project to inspect. Defaults to searching the current directory. | +| `-p, --package ` | install, list | Take skills from an exact package instead of a project. Repeatable. Refuses a floating version. | +| `-d, --destination ` | install, list | Where the tool copies skills to. Default `.agents/skills`. | +| `-d, --destination ` | uninstall | Where the tool removes skills from. Must match the destination you installed to. | +| `--global-packages ` | install, list | Overrides the NuGet global packages folder. | +| `-i, --interactive` | install | Lets you choose which new skills to add, with descriptions and pagination. Lists only skills that are not installed. Combines with `--target` or `--package`. | +| `-i, --interactive` | uninstall | Lets you choose which installed skills to remove, with descriptions and pagination. Lists only skills that this tool installed. | +| `-p, --package ` | uninstall | Removes only this package's skills. Removes whichever version is installed, or only the version you name. | +| `--stale` | uninstall | Removes only stale skills: a skill whose package the target no longer references, or whose package the target references at a different version. Needs a solution or project. Cannot combine with `--package`. | +| `-t, --target ` | uninstall | With `--stale`, the solution or project to compare against. Defaults to searching the current directory. | +| `--dry-run` | install, uninstall | Reports what would change, and writes nothing. | + +### Target another agent's folder + +`.agents/skills` is the vendor-neutral default destination. Point `--destination` anywhere else: + +```bash +dotnet-package-skills install --destination .claude/skills +dotnet-package-skills install --destination .codex/skills +``` + +`uninstall` takes the same option, and it needs that option. `uninstall` looks only where you +point it. To remove what you put in `.claude/skills`, you must name that folder again. + +```bash +dotnet-package-skills uninstall --destination .claude/skills +``` + +### Scripts and CI + +The tool writes reports for people to read. The manifest is its only machine-readable output. In +a script, rely on the exit code. The code is `0` when the command succeeded. The code is `1` when +the command stopped, and the reason appears on stderr. A command that stops changes nothing. An +argument error also prints help text on stdout. + +A job that keeps a committed skills folder in step with the project can run these two commands: + +```bash +dotnet-package-skills install +dotnet-package-skills uninstall --stale +``` + +To see what the tool installed, read `.agents/skills/.dotnet-package-skills.json`. [What you +get](#what-you-get) describes this file. `--interactive` needs a terminal, so leave it out of a +script. + +A report and a diagnostic message are both written for people, including an argument-validation +error and a parser suggestion. The tool removes terminal escape sequences and unsafe control +characters from metadata, paths, and diagnostic text before it shows them. This change affects +only the display. The tool still validates arguments exactly as you supplied them, and a stored +identity stays unchanged. + +## What you get + +Each authored skill folder lands directly under the destination: + +``` +.agents/skills/ +├── .dotnet-package-skills.json # what this tool copied in; do not hand-edit +├── contoso.widgets-widget-usage/ +│ ├── SKILL.md +│ └── references/ +│ └── batching.md +└── contoso.widgets-widget-testing/ + └── SKILL.md +``` + +The tool keeps the skill folder's name from the package. The package ID and version stay in the +install manifest, for attribution and for uninstall filtering, but the tool does not add them to +the path. + +The manifest follows the shape of the .NET local tool manifest, `dotnet-tools.json`. It has a +format `version`, and then one entry for each package, keyed by the lowercase package ID. Each +entry names the one version that its skills came from, and the skill folders it owns: + +```json +{ + "version": 1, + "packages": { + "contoso.widgets": { + "version": "2.3.0", + "skills": [ + "contoso.widgets-widget-testing", + "contoso.widgets-widget-usage" + ] + } + } +} +``` + +You can safely commit this file. The tool writes it the same way on every platform. It uses +UTF-8 without a byte order mark, LF line endings, and a stable order for its entries. Because of +this, a Windows checkout and a Linux checkout produce the same bytes. The tool ignores a property +that it does not recognize. It refuses a manifest with a newer format `version`, and it asks you +to update the tool instead. + +We recommend that a package author prefix every skill folder with the package's lowercased ID, as the +example above shows. This convention keeps names globally unique when skills from many packages +share one destination. The tool documents this convention. It does not enforce it. An existing +safe name still works. + +### Name collisions + +The tool compares destination names without regard to letter case. When two package skills +choose the same name, the tool copies the first one in a fixed package order. It skips a later +collision and shows a warning. An existing destination folder that this tool does not track +belongs to the user. The tool skips that folder too, and never overwrites it. + +The same protection applies to a name that a different package already owns. Every install mode +warns about this and keeps the current owner. It does not transfer the name automatically. +Uninstall the old skill explicitly before you install its replacement. Upgrading the same package +still works as expected. When both the current owner and another package offer the same name, +installation prefers the owner's candidate. This way, the conflict does not block a legitimate +refresh. + +One combination stops `install` instead of skipping a file. This happens when the owner's package +moves to a version that no longer ships the skill, while a different package ships a skill with +that same name. Removing the old copy would hand the name to the other package. Keeping the old +copy would record it under the new version's number, which would be wrong. For both reasons, +`install` changes nothing in this case. It suggests `uninstall --package ` for the owner. +After you run that command, `install` copies both packages' current skills. + +V1 does not reconcile two folders that differ only in physical case on a case-sensitive file +system. Keep an authored skill folder's casing stable across versions. Avoid folders such as +`guide` and `GUIDE` in the same destination. A case-only rename, or a collision between those +physical variants, can leave an untracked old copy behind, or it can overwrite a handwritten +variant. Both outcomes fall outside the v1 guarantees. + +A refresh of a tracked skill replaces its entire folder. This includes any local edits and any +files that you added. Keep hand-written guidance in a separate, untracked skill folder instead. + +### Package versions + +The destination holds skills from one version of each package, and the manifest records that one +version. +[NuGet Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) +keeps the projects in a repository on one version of each package. We recommend it for this +reason. When a target resolves a package to more than one version, `install` stops without +changing anything, and it names the versions that you need to align. `--package` stops the same +way when you name one package at two versions. `list` still shows every version that it finds. + +### Commit or ignore this folder + +Both choices are reasonable. Commit the folder so the whole team and your CI system get the +skills without running anything. Or add the folder to `.gitignore` and let each machine refresh +it on its own. Pick one choice, and state that choice in your own contributing guide. + +## For package authors: ship a skill + +Put each skill under `skills/-/`. Give it its own `SKILL.md` file, plus +any supporting files it needs. Prefix the folder with your lowercased package ID. This keeps your +skills from colliding with another package's skills on the consumer's machine. + +```xml + + + + +``` + +Every skill must have its own immediate subfolder under `skills/`. The tool does not discover a +lone `skills/SKILL.md` file. + +Give each skill a useful `description` in its YAML frontmatter, so a customer can decide whether +they need it: + +```yaml +--- +name: contoso.widgets-widget-usage +description: > + Correct usage patterns for Contoso.Widgets, including lifetime rules and batching. + Use when creating, configuring, or disposing a Widget. +--- +``` + +The tool supports a plain description, a quoted description, a literal (`|`) description, and a +folded (`>`) description. The interactive picker reads only bounded frontmatter. It never +interprets the Markdown instructions in the file, and it never rewrites the file. Description +metadata exists to inform the user. It is not an additional requirement for installation. +Frontmatter is limited to 65,536 decoded characters and 32 collection levels. The description +reader does not support an explicit YAML tag, anchor, or alias. These produce a visible metadata +warning instead of blocking installation. + +## How it works + +1. `dotnet list package --format json` finds the resolved direct packages. The tool + never restores a project on its own. The .NET 10 SDK restores the project during this step, + when it needs to. An earlier SDK instead says that the target needs to be restored first. When + this step fails, the tool shows what it reported, so you can restore or fix the target and run + the tool again. +2. `dotnet nuget locals global-packages --list` finds where restore extracted those packages. + `NUGET_PACKAGES` and `--global-packages` both take precedence over this step, in that order. +3. `install` stops without changing anything when a package resolves to more than one version, or + when a package that the target resolves is missing from the cache. +4. For each package, the tool looks in `///skills/`. +5. The tool copies each `skills//` folder to `//`. It skips a collision + and shows a warning instead. For a package that moved to a new version, the tool removes the + skills that the new version no longer ships. +6. The tool records what it copied in `/.dotnet-package-skills.json`. + +`uninstall --stale` needs only step 1. It compares the manifest with the target's package +references, and it never looks in the NuGet cache for skills. + +The tool never reads or interprets anything inside a skill. The package author decides what a +skill contains. This tool only places that content where an agent will look for it. + +### The tool only copies skills + +The global packages folder is NuGet's content-addressable cache. NuGet validates this folder +during restore, and every project on the machine shares it. If you move a file out of this +folder, restore may treat the cached package as damaged. Moving a file would also remove the +skill from every other repository that uses that package. + +### Removal is manifest-driven + +`.dotnet-package-skills.json` records what the tool copied in. `install` removes only the paths +listed there, when a package moves to a new version. `uninstall` removes only those listed paths +too. Neither command scans arbitrary folders. Keep hand-written guidance in a separate, untracked +folder. This folder is still subject to the v1 case-variant and linked-manifest limitations that +this document describes. + +When that manifest exists but the tool cannot read it, `install` and `uninstall` both stop +without changing anything. They keep the file in place so you can repair it. Resolve any merge +conflict in the file, or restore it from source control, before you try again. If you cannot +recover the file, move the whole destination folder aside before you install again. The tool will +not guess which existing folders it owns. + +The tool also refuses a manifest in three other cases. It refuses a manifest that names a newer +format version. Update the tool instead. It refuses a manifest that a pre-release build of this +tool wrote. Move the skills folder aside and install again instead. It refuses a manifest where a +package is missing its version, where a package ID is invalid, or where a skill is claimed twice. + +A skill name must identify a single folder directly inside the destination. The tool rejects a +name that ends in a dot or a space, including the name `...`, because Windows can resolve such a +name to a different folder or to the destination folder itself. A manifest that contains such a +name blocks both install and uninstall, including interactive mode and dry-run mode, before the +tool changes any skill file or manifest byte. + +By default, the tool creates an ordinary manifest file, and it updates an existing manifest in +place. V1 does not support a symbolic link or another kind of redirected manifest. The tool does +not create such a link, and it does not protect a link's target. An ordinary file system +operation can follow a link, including a link that already exists in a checked-out repository. +Use a regular manifest file in your skills destination. A customer who provides a link is +responsible for that link's effects. + +The tool serializes concurrent operations on the same destination. It rejects an interactive +choice if ownership changed before the tool could apply that choice. + +## A note on trust + +A bundled skill is a set of instructions that a third party wrote. Your agent will follow those +instructions, so a bundled skill is part of your software supply chain. This tool only copies +skills from a package that your project already depends on, and it prints every skill that it +copies, so you can review them. Treat a new skill the way you would treat any new dependency. + +## Common problems + +**"No bundled skills found"** This is the common and correct outcome. Most packages do not ship +skills. + +**"'dotnet list ... package' failed"** The tool reads the target's packages with `dotnet list +package`, and it shows what that command reported. For example, a restore may have failed, or an +earlier SDK may say that the target needs restoring. The tool never restores a project on its +own. Resolve what the tool reports, for example by running `dotnet restore`, and run the tool +again. + +**"resolved packages are missing from"** the NuGet cache. Run `dotnet restore` for the target and +try again. This message also appears when your packages come from a NuGet *fallback folder*, +which is common in a container or on a hosted build agent. Point `--global-packages` at that +folder. + +**"resolve to more than one version"** Projects in the target reference different versions of +one package. Align those versions, for example with Central Package Management, and try again. + +**"installed skills don't match the target"** This message comes from `install --interactive`. +Some installed skills are stale. Preview them with +`dotnet-package-skills uninstall --stale --dry-run`. Remove them with `uninstall --stale`, and +try again. + +**"is already installed, and an interactive install only adds skills"** `install --interactive +--package` named a package that is installed at a different version. Run `install --package` +without `--interactive` to move the package to the new version, or run `uninstall --package ` +first. + +**"Could not read the install manifest"** The manifest has a merge conflict, or someone edited it +into a shape that the tool cannot trust. See +[Removal is manifest-driven](#removal-is-manifest-driven). + +**"Unrecognized option '--format'"** Your SDK is older than 7.0.200. Upgrade it. + +**The tool reads the wrong global packages folder.** `nuget.config` discovery walks up from the +current directory. Run the tool from your repository root instead, or pass `--global-packages` +explicitly. + +**A solution filter (`.slnf`) is rejected.** Not every SDK accepts a solution filter with +`dotnet list package`. Pass the underlying `.sln` file instead, or run the tool once for each +project with `--target`. + +## Build from source + +Run these commands from the root of a Client.Tools checkout. The root `global.json` selects the +pinned .NET SDK and Arcade SDK. The root `NuGet.Config` selects the public restore feeds. +Install PowerShell 7. Install the .NET 8 runtime into the same .NET installation that Arcade +uses. The SDK supplies the .NET 10 runtime. The CI system validates these commands on Windows. + +```powershell +eng\common\build.cmd -restore -build -test -configuration Release +eng\common\build.cmd -pack -configuration Release /p:NoBuild=true +eng\common\build.cmd -sign -configuration Release /p:NETCORE_ENGINEERING_TELEMETRY=false +eng\pipelines\dotnet-package-skills\Invoke-Build.ps1 -Action Verify +``` + +Arcade keeps the binaries under `artifacts\bin\DotnetPackageSkills\Release` and the package under +`artifacts\packages\Release\Shipping`. The signing command above is a dry run. It needs no +production connection and leaves the package unsigned. + +The verifier reads the expected version and paths from Arcade. It installs the exact package into a temporary tool path, once for +.NET 8 and once for .NET 10. It runs the package's non-interactive commands, and then it removes +its own temporary files. It does not replace a tool that you installed globally, and it does not +change your installed skills. + +A local build uses `0.1.0-dev`. A public CI or PR build uses `0.1.0-ci`. +An ordinary official build uses `0.1.0-beta..`. +Only an explicit manual release run from trusted internal main produces `0.1.0`. +The reviewed base version lives in `eng\Versions.props`. Builds publish pipeline artifacts, +not packages to a feed. See the +[pipeline and release guide](../eng/pipelines/dotnet-package-skills/README.md) for more +information. + +## License + +MIT diff --git a/dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs b/dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs new file mode 100644 index 0000000..5267761 --- /dev/null +++ b/dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs @@ -0,0 +1,47 @@ +using System.CommandLine; + +namespace DotnetPackageSkills.Cli; + +/// Sanitizes framework diagnostics without changing arguments or application console output. +internal static class CommandLineDiagnostics +{ + public static int Invoke(ParseResult result, TextWriter output, TextWriter error) + { + // Suggestions use Output, not Error. Buffer both through the whole invocation so + // split escape sequences, surrogate pairs, and Flush calls cannot bypass sanitizing. + using var capturedOutput = new StringWriter(output.FormatProvider) { NewLine = output.NewLine }; + using var capturedError = new StringWriter(error.FormatProvider) { NewLine = error.NewLine }; + + try + { + return result.Invoke(new InvocationConfiguration + { + Output = capturedOutput, + Error = capturedError, + }); + } + finally + { + Write(error, capturedError.ToString()); + Write(output, capturedOutput.ToString()); + } + } + + private static void Write(TextWriter writer, string text) + { + if (text.Length == 0) + { + return; + } + + var clean = TerminalText.Sanitize(text, multiline: true, trim: false); + + if (text.EndsWith('\n') && !clean.EndsWith('\n')) + { + // An unterminated control string can also consume the framework's final newline. + clean += "\n"; + } + + writer.Write(clean.Replace("\n", writer.NewLine, StringComparison.Ordinal)); + } +} diff --git a/dotnet-package-skills/src/Cli/ConsoleViewport.cs b/dotnet-package-skills/src/Cli/ConsoleViewport.cs new file mode 100644 index 0000000..7f8980c --- /dev/null +++ b/dotnet-package-skills/src/Cli/ConsoleViewport.cs @@ -0,0 +1,132 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace DotnetPackageSkills.Cli; + +internal static class ConsoleViewport +{ + public static (int Width, int Height) Size() + { + if (!OperatingSystem.IsWindows()) + { + return (Console.WindowWidth, Console.WindowHeight); + } + + var buffer = ReadBuffer(); + return (buffer.Window.Right - buffer.Window.Left + 1, buffer.Window.Bottom - buffer.Window.Top + 1); + } + + public static void SetCursorPosition(int left, int top) + { + if (!OperatingSystem.IsWindows()) + { + Console.SetCursorPosition(left, top); + return; + } + + var buffer = ReadBuffer(); + var position = new Coordinate + { + X = (short)Math.Clamp(buffer.Window.Left + left, buffer.Window.Left, buffer.Window.Right), + Y = (short)Math.Clamp(buffer.Window.Top + top, buffer.Window.Top, buffer.Window.Bottom), + }; + if (!SetConsoleCursorPosition(GetStdHandle(-11), position)) + { + throw ConsoleError("Could not position the interactive terminal cursor."); + } + } + + public static void Clear() + { + if (OperatingSystem.IsWindows()) + { + ClearWindows(); + } + else + { + Console.Write("\x1b[2J\x1b[H"); + } + } + + [SupportedOSPlatform("windows")] + private static void ClearWindows() + { + var output = GetStdHandle(-11); + var buffer = ReadBuffer(); + + // Clear cells in place. Printing blank lines instead pushes stale picker frames + // into scrollback, where the terminal can rewrap them independently after a resize. + var width = (uint)(buffer.Window.Right - buffer.Window.Left + 1); + for (var row = (int)buffer.Window.Top; row <= buffer.Window.Bottom; row++) + { + var position = new Coordinate { X = buffer.Window.Left, Y = (short)row }; + if (!FillConsoleOutputCharacter(output, ' ', width, position, out _) || + !FillConsoleOutputAttribute(output, buffer.Attributes, width, position, out _)) + { + throw ConsoleError("Could not clear the interactive terminal viewport."); + } + } + } + + [SupportedOSPlatform("windows")] + private static ScreenBufferInfo ReadBuffer() + { + if (!GetConsoleScreenBufferInfo(GetStdHandle(-11), out var buffer)) + { + throw ConsoleError("Could not read the interactive terminal viewport."); + } + + return buffer; + } + + private static IOException ConsoleError(string message) => + new(message, new Win32Exception(Marshal.GetLastPInvokeError())); + + [StructLayout(LayoutKind.Sequential)] + private struct Coordinate + { + public short X; + public short Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WindowRectangle + { + public short Left; + public short Top; + public short Right; + public short Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ScreenBufferInfo + { + public Coordinate Size; + public Coordinate Cursor; + public ushort Attributes; + public WindowRectangle Window; + public Coordinate MaximumWindowSize; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle(int handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetConsoleScreenBufferInfo(nint output, out ScreenBufferInfo info); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetConsoleCursorPosition(nint output, Coordinate position); + + [DllImport("kernel32.dll", EntryPoint = "FillConsoleOutputCharacterW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FillConsoleOutputCharacter( + nint output, char character, uint length, Coordinate position, out uint written); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FillConsoleOutputAttribute( + nint output, ushort attributes, uint length, Coordinate position, out uint written); +} diff --git a/dotnet-package-skills/src/Cli/ITerminal.cs b/dotnet-package-skills/src/Cli/ITerminal.cs new file mode 100644 index 0000000..f5a4461 --- /dev/null +++ b/dotnet-package-skills/src/Cli/ITerminal.cs @@ -0,0 +1,281 @@ +using System.Diagnostics; +using System.Text; + +namespace DotnetPackageSkills.Cli; + +internal enum TerminalStyle +{ + Default, + Focus, + Selected, + Muted, +} + +internal readonly record struct TerminalState( + bool CursorVisible, + bool TreatControlCAsInput, + ConsoleColor? Foreground, + ConsoleColor? Background, + TerminalStyle Style, + Encoding OutputEncoding); + +/// The console operations the interactive picker needs, in viewport coordinates. +internal interface ITerminal +{ + bool IsRedirected { get; } + + bool SupportsColor { get; } + + int WindowHeight { get; } + + int WindowWidth { get; } + + (int Width, int Height) GetWindowSize() => (WindowWidth, WindowHeight); + + int CursorTop { get; } + + bool CursorVisible { set; } + + bool TreatControlCAsInput { set; } + + TerminalState CaptureState(); + + void RestoreState(TerminalState state); + + /// Uses lossless Unicode output for this interaction; RestoreState restores the encoding. + void UseUtf8Output(); + + IDisposable EnterInteractiveScreen(); + + void SetStyle(TerminalStyle style); + + void ResetStyle(); + + void SetCursorPosition(int left, int top); + + void Write(string text); + + void WriteLine(string text = ""); + + /// Starts a fresh viewport for the initial frame or a resize, without discarding scrollback. + void ClearViewport(); + + /// Waits at most the timeout; false lets the picker observe an idle resize. + bool TryReadKey(TimeSpan timeout, out ConsoleKeyInfo key); + + ConsoleKeyInfo ReadKey(); +} + +/// An over the real console. +internal sealed class SystemTerminal : ITerminal +{ + private const int FallbackHeight = 24; + private const int FallbackWidth = 80; + private TerminalStyle _style; + private TerminalStyle? _appliedStyle; + + public bool IsRedirected => Console.IsInputRedirected || Console.IsOutputRedirected; + + public bool SupportsColor => CanUseColor( + IsRedirected, + Environment.GetEnvironmentVariable("NO_COLOR"), + Environment.GetEnvironmentVariable("TERM"), + OperatingSystem.IsWindows()); + + public int WindowHeight => GetWindowSize().Height; + + public int WindowWidth => GetWindowSize().Width; + + public (int Width, int Height) GetWindowSize() + { + var (width, height) = Read(ConsoleViewport.Size, (FallbackWidth, FallbackHeight)); + return (width > 0 ? width : FallbackWidth, height > 0 ? height : FallbackHeight); + } + + public int CursorTop => Math.Clamp( + Read(static () => Console.CursorTop, 0) - Read(static () => Console.WindowTop, 0), + 0, + WindowHeight - 1); + + public bool CursorVisible + { + set => Ignoring(() => Console.CursorVisible = value); + } + + public bool TreatControlCAsInput + { + set => Ignoring(() => Console.TreatControlCAsInput = value); + } + + public TerminalState CaptureState() => new( + Read(static () => OperatingSystem.IsWindows() ? Console.CursorVisible : true, true), + Read(static () => Console.TreatControlCAsInput, false), + ReadColor(static () => Console.ForegroundColor), + ReadColor(static () => Console.BackgroundColor), + _style, + Console.OutputEncoding); + + public void UseUtf8Output() => Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + public IDisposable EnterInteractiveScreen() => InteractiveScreen.Enter(); + + public void RestoreState(TerminalState state) + { + try + { + ResetStyle(); + if (SupportsColor) + { + if (state.Foreground is { } foreground) + { + Ignoring(() => Console.ForegroundColor = foreground); + } + + if (state.Background is { } background) + { + Ignoring(() => Console.BackgroundColor = background); + } + } + + _style = state.Style; + _appliedStyle = null; + } + finally + { + try + { + CursorVisible = state.CursorVisible; + } + finally + { + try + { + TreatControlCAsInput = state.TreatControlCAsInput; + } + finally + { + Console.OutputEncoding = state.OutputEncoding; + } + } + } + } + + public void SetStyle(TerminalStyle style) + { + var supportsColor = SupportsColor; + _style = supportsColor ? style : TerminalStyle.Default; + if (!supportsColor || _appliedStyle == style) + { + return; + } + + Ignoring(Console.ResetColor); + var color = style switch + { + TerminalStyle.Focus => ConsoleColor.Blue, + TerminalStyle.Selected => ConsoleColor.Blue, + TerminalStyle.Muted => ConsoleColor.DarkGray, + _ => (ConsoleColor?)null, + }; + + if (color is { } foreground) + { + Ignoring(() => Console.ForegroundColor = foreground); + } + + _appliedStyle = style; + } + + public void ResetStyle() => SetStyle(TerminalStyle.Default); + + public void SetCursorPosition(int left, int top) => ConsoleViewport.SetCursorPosition(left, top); + + public void Write(string text) => Console.Write(text); + + public void WriteLine(string text = "") => Console.WriteLine(text); + + public void ClearViewport() + { + ConsoleViewport.Clear(); + SetCursorPosition(0, 0); + } + + public ConsoleKeyInfo ReadKey() => Console.ReadKey(intercept: true); + + public bool TryReadKey(TimeSpan timeout, out ConsoleKeyInfo key) + { + ArgumentOutOfRangeException.ThrowIfLessThan(timeout, TimeSpan.Zero); + var started = Stopwatch.GetTimestamp(); + while (true) + { + if (Console.KeyAvailable) + { + key = ReadKey(); + return true; + } + + var remaining = timeout - Stopwatch.GetElapsedTime(started); + if (remaining <= TimeSpan.Zero) + { + key = default; + return false; + } + + // Keep key latency low without spinning, including the final fractional millisecond. + Thread.Sleep((int)Math.Clamp(Math.Ceiling(remaining.TotalMilliseconds), 1, 25)); + } + } + + internal static bool CanUseColor(bool redirected, string? noColor, string? term, bool windows) + { + if (redirected || noColor is not null) + { + return false; + } + + var capability = term?.ToLowerInvariant(); + if (capability is "dumb" or "unknown" or "vt100" or "vt102" or "vt220") + { + return false; + } + + return windows || + capability is "linux" or "ansi" or "cygwin" || + capability is not null && + (capability.Contains("color", StringComparison.Ordinal) || + capability.StartsWith("xterm", StringComparison.Ordinal) || + capability.StartsWith("screen", StringComparison.Ordinal) || + capability.StartsWith("tmux", StringComparison.Ordinal) || + capability.StartsWith("rxvt", StringComparison.Ordinal)); + } + + private static ConsoleColor? ReadColor(Func read) + { + var color = Read(read, (ConsoleColor)(-1)); + return (int)color is >= 0 and <= 15 ? color : null; + } + + private static T Read(Func read, T fallback) + { + try + { + return read(); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or InvalidOperationException) + { + return fallback; + } + } + + private static void Ignoring(Action action) + { + try + { + action(); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or + ArgumentOutOfRangeException or InvalidOperationException) + { + } + } +} diff --git a/dotnet-package-skills/src/Cli/InteractiveScreen.cs b/dotnet-package-skills/src/Cli/InteractiveScreen.cs new file mode 100644 index 0000000..afa9cbf --- /dev/null +++ b/dotnet-package-skills/src/Cli/InteractiveScreen.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace DotnetPackageSkills.Cli; + +/// Keeps transient picker frames out of the shell's reflowable scrollback. +internal sealed class InteractiveScreen : IDisposable +{ + private const uint EnableProcessedOutput = 0x0001; + private const uint EnableVirtualTerminalProcessing = 0x0004; + private readonly nint _output; + private readonly uint? _originalMode; + private bool _disposed; + + private InteractiveScreen(nint output, uint? originalMode) + { + _output = output; + _originalMode = originalMode; + } + + public static InteractiveScreen Enter() + { + nint output = nint.Zero; + uint? originalMode = null; + if (OperatingSystem.IsWindows()) + { + output = GetStdHandle(-11); + if (!GetConsoleMode(output, out var mode) || + !SetConsoleMode(output, mode | EnableProcessedOutput | EnableVirtualTerminalProcessing)) + { + throw new PackageSkillsException( + "This terminal cannot open an interactive screen. Use a terminal with virtual-terminal support " + + "or run the command without --interactive.", + new Win32Exception(Marshal.GetLastPInvokeError())); + } + + originalMode = mode; + } + + var screen = new InteractiveScreen(output, originalMode); + try + { + Console.Write("\x1b[?1049h"); + Console.Out.Flush(); + return screen; + } + catch + { + screen.Dispose(); + throw; + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + try + { + Console.Write("\x1b[?1049l"); + Console.Out.Flush(); + } + finally + { + if (_originalMode is { } mode && !SetConsoleMode(_output, mode)) + { + throw new IOException("Could not restore the terminal output mode.", + new Win32Exception(Marshal.GetLastPInvokeError())); + } + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle(int handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetConsoleMode(nint handle, out uint mode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetConsoleMode(nint handle, uint mode); +} diff --git a/dotnet-package-skills/src/Cli/InteractiveSkills.cs b/dotnet-package-skills/src/Cli/InteractiveSkills.cs new file mode 100644 index 0000000..d5cd537 --- /dev/null +++ b/dotnet-package-skills/src/Cli/InteractiveSkills.cs @@ -0,0 +1,73 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Cli; + +internal sealed record UninstallChoice( + IReadOnlyCollection Selected, + IReadOnlyCollection ExpectedInstalled); + +internal static class InteractiveSkills +{ + /// + /// Checklist items for install, which only adds. Installed skills are never offered, so + /// nothing on the list can refresh, replace or remove a skill the user already has. + /// + public static IReadOnlyList ForInstall( + IReadOnlyList candidates, + IReadOnlyCollection installed) + { + var tracked = installed.Select(entry => entry.Skill).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return + [ + .. candidates + .Where(skill => !tracked.Contains(skill.RelativePath)) + .Select(skill => Describe(skill.RelativePath, skill.PackageId, skill.PackageVersion, skill.SourcePath)) + .OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.Name, StringComparer.Ordinal), + ]; + } + + public static IReadOnlyList ForUninstall( + IReadOnlyList skills, + string destination) => + [ + .. skills.Select(skill => Describe( + skill.Skill, + skill.Package, + skill.Version, + Path.Combine(destination, skill.Skill))), + ]; + + /// The checked skills that were actually shown. Nothing else is installed or changed. + public static SkillChoice InstallChoice( + IReadOnlyList candidates, + IReadOnlyCollection installed, + IReadOnlyList shown, + IReadOnlySet selected) => + new( + [ + .. candidates.Where(skill => selected.Contains(skill.RelativePath) && + shown.Any(item => + item.Name.Equals(skill.RelativePath, StringComparison.OrdinalIgnoreCase) && + item.Package.Equals(skill.PackageId, StringComparison.OrdinalIgnoreCase))), + ]) + { + ExpectedInstalled = installed, + }; + + private static SkillPickerItem Describe( + string name, + string package, + string version, + string skillDirectory) + { + var metadata = SkillDescriptionReader.Read(skillDirectory); + return new SkillPickerItem( + name, + package, + version, + metadata.Description, + metadata.Warning); + } +} diff --git a/dotnet-package-skills/src/Cli/OutputWriter.cs b/dotnet-package-skills/src/Cli/OutputWriter.cs new file mode 100644 index 0000000..97ca208 --- /dev/null +++ b/dotnet-package-skills/src/Cli/OutputWriter.cs @@ -0,0 +1,203 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Cli; + +/// Renders results for people. There is no machine-readable report. +public sealed class OutputWriter(TextWriter output, TextWriter? errorOutput = null) +{ + /// + /// False for list, which discovers without writing, so the report says "Found" + /// rather than claiming files were placed. + /// + public void WriteInstallReport(InstallResult result, bool copied) + { + WriteContext(result); + + var verb = copied + ? result.DryRun ? "Would copy" : "Copied" + // list discovers without writing, and it always runs as a dry run, so asking + // DryRun first would make this branch unreachable and claim a copy was pending. + : "Found"; + + if (result.Skills.Count > 0) + { + output.WriteLine($"{verb} {Count(result.Skills.Count, "skill")}:"); + + foreach (var skill in result.Skills) + { + output.WriteLine($" {Describe(skill.RelativePath, skill.PackageId, skill.PackageVersion)}"); + } + } + else if (result.NothingNewToInstall) + { + // An interactive install lists only skills that are not installed. With nothing left + // to list there was no checklist, and the skipped section below says why when some + // skills could not be offered. + output.WriteLine(result.Skipped.Count == 0 + ? "Nothing new to install. Every skill that these packages ship is already installed." + : "Nothing new to install."); + } + else if (result.SkillsDiscovered > 0) + { + // Packages did ship skills; none of them ended up installed, because they were + // deselected or skipped. Saying nobody ships a skill here would be a lie, and the + // sections below already explain what happened to each one. + output.WriteLine($"{verb} no skills."); + } + else + { + // Packages missing from the cache look exactly like packages without skills, so + // claim nothing about why the list is empty. + output.WriteLine("No bundled skills found."); + } + + if (result.Removed.Count > 0) + { + output.WriteLine(); + output.WriteLine( + $"{(result.DryRun ? "Would remove" : "Removed")} {Count(result.Removed.Count, "skill")}:"); + + foreach (var entry in result.Removed) + { + output.WriteLine($" {Describe(entry.Skill, entry.Package, entry.Version)}"); + } + } + + WriteUnreferenced(result); + WriteSkipped(result); + + if (result.Skills.Count > 0 && copied && !result.DryRun) + { + output.WriteLine(); + + // One line, however long. Any break we choose is a guess at the reader's width, + // and the terminal already knows theirs. + output.WriteLine( + "These skills are instructions written by the package authors, " + + "and your coding agent will follow them. Review them before relying on them."); + } + } + + private void WriteContext(InstallResult result) + { + output.WriteLine($"Target: {TerminalText.Sanitize(result.Target ?? "(packages named on the command line)")}"); + output.WriteLine($"NuGet cache: {TerminalText.Sanitize(result.GlobalPackagesFolder)}"); + output.WriteLine($"Destination: {TerminalText.Sanitize(result.Destination)}"); + + var scope = result.Target is null ? "named explicitly" : "direct"; + + output.WriteLine($"Scanned {Count(result.PackagesScanned, "package")} ({scope})."); + output.WriteLine(); + } + + /// + /// Install never removes a skill because its package left the project, so say which ones + /// stayed and which command removes them. + /// + private void WriteUnreferenced(InstallResult result) + { + if (result.Unreferenced.Count == 0) + { + return; + } + + var one = result.Unreferenced.Count == 1; + var packages = result.Unreferenced + .Select(entry => entry.Package) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count() == 1 + ? "a package" + : "packages"; + + output.WriteLine(); + output.WriteLine( + $"{Count(result.Unreferenced.Count, "installed skill")} {(one ? "belongs" : "belong")} to " + + $"{packages} that the target no longer references:"); + + foreach (var entry in result.Unreferenced) + { + output.WriteLine($" {Describe(entry.Skill, entry.Package, entry.Version)}"); + } + + output.WriteLine( + $"Run '{TerminalText.Sanitize(result.StaleCommand)}' to remove {(one ? "it" : "them")}."); + } + + private void WriteSkipped(InstallResult result) + { + if (result.Skipped.Count == 0) + { + return; + } + + output.WriteLine(); + output.WriteLine($"Warning: skipped {Count(result.Skipped.Count, "colliding skill")}:"); + + foreach (var skill in result.Skipped) + { + output.WriteLine($" {Describe(skill.RelativePath, skill.PackageId, skill.PackageVersion)}"); + output.WriteLine($" {TerminalText.Sanitize(skill.Reason)}"); + } + } + + /// + /// The solution or project that uninstall --stale compared against, or null for a + /// plain uninstall. + /// + public void WriteUninstallReport( + IReadOnlyList removed, + string destination, + bool dryRun, + string? target = null) + { + if (target is not null) + { + output.WriteLine($"Target: {TerminalText.Sanitize(target)}"); + } + + output.WriteLine($"Destination: {TerminalText.Sanitize(destination)}"); + output.WriteLine(); + + if (removed.Count == 0) + { + output.WriteLine(target is null + ? "Nothing to remove. No skills installed by this tool were found there." + : "Nothing to remove. No stale skills were found."); + return; + } + + output.WriteLine($"{(dryRun ? "Would remove" : "Removed")} {Count(removed.Count, "skill")}:"); + + foreach (var entry in removed) + { + output.WriteLine($" {Describe(entry.Skill, entry.Package, entry.Version)}"); + } + } + + public void WriteError(string message) + { + var text = TerminalText.Sanitize(message, multiline: true).Replace("\n", Environment.NewLine); + (errorOutput ?? Console.Error).WriteLine($"error: {text}"); + } + + /// + /// Reported when the user leaves the interactive picker without confirming. Nothing failed, + /// so this is a statement of fact rather than an error. + /// + public void WriteCancelled() + { + output.WriteLine("Cancelled. Nothing was copied or removed."); + } + + private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}"; + + /// One skill on one line: the folder name, then who it came from. + /// + /// This used to be two lines, with "from Package Version" indented underneath. That doubled + /// the length of every report to carry a word — "from" — that the brackets say for free, and + /// twelve skills read far more easily as twelve lines than as twenty-four. + /// Sanitize fields separately so an unterminated control in one cannot hide the next. + /// + private static string Describe(string skill, string package, string version) => + $"{TerminalText.Sanitize(skill)} ({TerminalText.Sanitize(package)} {TerminalText.Sanitize(version)})"; +} diff --git a/dotnet-package-skills/src/Cli/PickerLayout.cs b/dotnet-package-skills/src/Cli/PickerLayout.cs new file mode 100644 index 0000000..e752e5d --- /dev/null +++ b/dotnet-package-skills/src/Cli/PickerLayout.cs @@ -0,0 +1,296 @@ +namespace DotnetPackageSkills.Cli; + +/// A selection-independent layout, rebuilt only when the viewport changes. +internal sealed class PickerLayout +{ + internal const string PrimaryHelp = "(Press to select, to accept)"; + + internal sealed record Entry(string Label, int DescriptionColumn, IReadOnlyList Description) + { + public int Height => Description.Count; + } + + internal sealed record Page(int First, int Count, int VisibleRows, bool Scrollable); + + private readonly string _title; + private readonly string? _note; + private readonly int _headerRows; + private readonly int _footerRows; + private readonly int[] _itemPages; + + private PickerLayout( + int windowWidth, + int windowHeight, + int width, + bool supportsColor, + string title, + string? note, + IReadOnlyList entries, + IReadOnlyList pages, + IReadOnlyList help, + int headerRows, + int footerRows) + { + WindowWidth = windowWidth; + WindowHeight = windowHeight; + Width = width; + SupportsColor = supportsColor; + _title = title; + _note = note; + Entries = entries; + Pages = pages; + Help = help; + _headerRows = headerRows; + _footerRows = footerRows; + _itemPages = new int[entries.Count]; + for (var page = 0; page < pages.Count; page++) + { + Array.Fill(_itemPages, page, pages[page].First, pages[page].Count); + } + } + + public int WindowWidth { get; } + + public int WindowHeight { get; } + + public int Width { get; } + + public bool SupportsColor { get; } + + /// + /// Where a wrapped description continues: past the cursor, the checkbox, and a space. Both + /// pickers draw a row the same way, with or without color. + /// + public int ContinuationColumn => RowPrefix; + + private const int RowPrefix = 6; + + public IReadOnlyList Entries { get; } + + public IReadOnlyList Pages { get; } + + public IReadOnlyList Help { get; } + + public int MaxFrameHeight => Pages.Max(page => + _headerRows + 2 + _footerRows + page.VisibleRows + + (page.Scrollable ? ScrollHelpRows(Entries[page.First].Height, Width) : 0)); + + public int PageIndexFor(int item) => _itemPages[item]; + + public int MaxScroll(int item) + { + var page = Pages[PageIndexFor(item)]; + return page.Scrollable ? Entries[item].Height - page.VisibleRows : 0; + } + + public IReadOnlyList Header(int page) => Header(_title, _note, page + 1, Pages.Count, Width); + + public static PickerLayout For( + IReadOnlyList items, + string title, + PickerMode mode, + int windowWidth, + int windowHeight, + bool supportsColor, + string? note = null) + { + if (note is null) + { + return Build(items, title, mode, windowWidth, windowHeight, supportsColor, note: null); + } + + try + { + return Build(items, title, mode, windowWidth, windowHeight, supportsColor, note); + } + catch (PackageSkillsException) + { + // The note only explains what the list leaves out. A window without room for it + // keeps the checklist and loses the note, rather than refusing to open. + return Build(items, title, mode, windowWidth, windowHeight, supportsColor, note: null); + } + } + + private static PickerLayout Build( + IReadOnlyList items, + string title, + PickerMode mode, + int windowWidth, + int windowHeight, + bool supportsColor, + string? note) + { + var labels = items.Select(item => TerminalText.Sanitize(item.Name)).ToArray(); + var descriptions = items.Select(DescriptionFor).ToArray(); + var cleanTitle = TerminalText.Sanitize(title); + var cleanNote = note is null ? null : TerminalText.Sanitize(note); + var longestName = labels.Max(TerminalText.Width); + var longestDescription = descriptions.Max(description => description.Split('\n').Max(TerminalText.Width)); + var prefix = RowPrefix; + var summaryBounds = new[] { Summary(items.Count, items.Count, mode) }; + var helpWidths = HelpFor(items.Count, items.Count, supportsColor).Select(TerminalText.Width); + var naturalWidth = new[] + { + labels.Select((label, index) => prefix + TerminalText.Width(label) + 3 + + descriptions[index].Split('\n').Max(TerminalText.Width)).Max(), + TerminalText.Width(cleanTitle) + (items.Count > 1 ? 2 + Counter(items.Count, items.Count).Length : 0), + cleanNote is null ? 0 : TerminalText.Width(cleanNote), + summaryBounds.Max(TerminalText.Width), + helpWidths.Max(), + }.Max(); + + // Leave a column and a row untouched so neither padding nor a final newline can + // force an automatic wrap in the middle of a frame. + var width = Math.Min(naturalWidth, windowWidth - 1); + var available = width - prefix - 3; + var widestGrapheme = descriptions.SelectMany(TerminalText.Elements).Max(TerminalText.CellWidth); + if (available < 1 + widestGrapheme) + { + throw TooSmall(windowWidth, windowHeight, $"at least {prefix + 3 + widestGrapheme + 2} columns"); + } + + // When both columns want more than the window, give the description at least half + // the remaining cells. Short descriptions give that space back to a long name. + var descriptionReserve = Math.Max(widestGrapheme, Math.Min(longestDescription, available / 2)); + var nameWidth = Math.Min(longestName, available - descriptionReserve); + var entries = labels.Select((label, index) => + { + var displayName = TerminalText.Clip(label, nameWidth); + var descriptionColumn = prefix + TerminalText.Width(displayName) + 3; + return new Entry( + displayName, + descriptionColumn, + TerminalText.Wrap(descriptions[index], width - descriptionColumn, width - prefix)); + }).ToArray(); + + var pageCount = 1; + while (true) + { + var headerRows = Header(cleanTitle, cleanNote, pageCount, pageCount, width).Count; + var help = HelpFor(items.Count, pageCount, supportsColor) + .SelectMany(line => TerminalText.Wrap(line, width)).ToArray(); + var footerRows = summaryBounds.Max(summary => TerminalText.Wrap(summary, width).Count) + help.Length; + var budget = windowHeight - 1 - headerRows - 2 - footerRows; + if (budget < 1) + { + throw TooSmall(windowWidth, windowHeight, $"at least {windowHeight + 1 - budget} rows at this width"); + } + + var pages = Paginate(entries, budget, width, windowWidth, windowHeight); + if (pages.Count == pageCount) + { + return new PickerLayout( + windowWidth, windowHeight, width, supportsColor, cleanTitle, cleanNote, + entries, pages, help, headerRows, footerRows); + } + + // Only paging chrome and counter digit growth can shrink the row budget. + // Iterating to a fixed point avoids guessing how many rows that chrome uses. + pageCount = pages.Count; + } + } + + /// + /// Nothing on an install list is installed, so every tick is one install and the count needs + /// no second number. Every tick on an uninstall list is one removal, which is worth saying. + /// The widest summary is the one with every row ticked, so that is what layout measures. + /// + public static string Summary(int selected, int total, PickerMode mode) => + mode == PickerMode.Uninstall + ? $"{selected} of {total} selected; {selected} to remove" + : $"{selected} of {total} selected"; + + public static string ScrollHelp(int first, int last, int total) => + $"(Press / to scroll description: {first}-{last}/{total})"; + + private static int ScrollHelpRows(int lines, int width) => + TerminalText.Wrap(ScrollHelp(lines, lines, lines), width).Count; + + private static List Paginate( + IReadOnlyList entries, + int budget, + int width, + int windowWidth, + int windowHeight) + { + var pages = new List(); + for (var first = 0; first < entries.Count;) + { + if (entries[first].Height > budget) + { + var visible = budget - ScrollHelpRows(entries[first].Height, width); + // Keep the skill row visible alongside at least one scrolling continuation. + if (visible < 2) + { + throw TooSmall(windowWidth, windowHeight, $"at least {windowHeight + 2 - visible} rows at this width"); + } + + pages.Add(new Page(first++, 1, visible, Scrollable: true)); + continue; + } + + var rows = 0; + var end = first; + while (end < entries.Count && rows + entries[end].Height <= budget) + { + rows += entries[end++].Height; + } + + pages.Add(new Page(first, end - first, rows, Scrollable: false)); + first = end; + } + + return pages; + } + + private static string DescriptionFor(SkillPickerItem item) + { + if (!string.IsNullOrWhiteSpace(item.DescriptionWarning)) + { + var warning = TerminalText.Sanitize(item.DescriptionWarning); + return $"Description unavailable: {(TerminalText.Width(warning) > 0 ? warning : "unreadable metadata.")}"; + } + + var description = TerminalText.Sanitize(item.Description, multiline: true); + return TerminalText.Width(description) > 0 ? description : "No description provided."; + } + + private static IReadOnlyList Header(string title, string? note, int page, int pages, int width) + { + var text = pages > 1 ? $"{title} {Counter(page, pages)}" : title; + IReadOnlyList lines = TerminalText.Width(text) <= width ? [text] : TerminalText.Wrap(text, width); + return note is null ? lines : [.. lines, .. TerminalText.Wrap(note, width)]; + } + + private static string Counter(int page, int pages) => $"page {page} of {pages}"; + + private static IEnumerable HelpFor(int items, int pages, bool supportsColor) + { + yield return PrimaryHelp; + if (items > 1) + { + yield return "(Press / to move, / for first/last)"; + } + + if (pages > 1) + { + yield return "(Press /, / to change page)"; + } + + yield return items > 1 + ? "(Press to select all, to clear all, // to cancel)" + : "(Press // to cancel)"; + + // Each checklist does one thing, so a tick needs no cue for what it does: the title and + // the summary say that. The legend only explains the color, so without color it goes. + if (supportsColor) + { + yield return "Blue X: selected"; + } + } + + private static PackageSkillsException TooSmall(int width, int height, string minimum) => new( + $"The terminal is too small for the interactive checklist ({width}x{height}). " + + $"Enlarge the window to {minimum}, or use the command without --interactive " + + "and with --package to limit the operation."); +} diff --git a/dotnet-package-skills/src/Cli/SkillPicker.cs b/dotnet-package-skills/src/Cli/SkillPicker.cs new file mode 100644 index 0000000..4dffe99 --- /dev/null +++ b/dotnet-package-skills/src/Cli/SkillPicker.cs @@ -0,0 +1,379 @@ +namespace DotnetPackageSkills.Cli; + +internal enum PickerMode +{ + Install, + Uninstall, +} + +/// Picker metadata supplied by the caller, never loaded by the UI. +internal sealed record SkillPickerItem( + string Name, + string Package, + string Version, + string? Description = null, + string? DescriptionWarning = null); + +/// +/// A paged checklist. Nothing starts ticked: a tick means install in install mode and remove in +/// uninstall mode, so accepting without a choice changes nothing either way. +/// +internal sealed class SkillPicker(ITerminal terminal) +{ + private static readonly TimeSpan InputPollInterval = TimeSpan.FromMilliseconds(100); + + /// An optional line shown under the title, such as what the list leaves out. + public IReadOnlySet? Choose( + IReadOnlyList items, + string title, + PickerMode mode = PickerMode.Install, + string? note = null) + { + if (items.Count == 0) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + if (terminal.IsRedirected) + { + throw new PackageSkillsException( + mode == PickerMode.Install + ? "--interactive needs a terminal, but input or output is redirected. " + + "Drop --interactive to install every discovered skill, or name the ones you want " + + "with --package." + : "--interactive needs a terminal, but input or output is redirected. " + + "Drop --interactive to remove every skill that the command matches, and add " + + "--dry-run to see which ones first."); + } + + var selected = new HashSet(); + var layout = Measure(); + var cursor = 0; + int? pageOffset = null; + var scroll = new int[items.Count]; + var frameTop = 0; + var height = 0; + var frameStarted = false; + var resetViewport = true; + var original = terminal.CaptureState(); + IDisposable? screen = null; + + try + { + terminal.UseUtf8Output(); + screen = terminal.EnterInteractiveScreen(); + terminal.CursorVisible = false; + terminal.TreatControlCAsInput = true; + terminal.ResetStyle(); + frameStarted = true; + + while (true) + { + Reflow(); + DrawFrame(); + ConsoleKeyInfo key; + while (!terminal.TryReadKey(InputPollInterval, out key)) + { + if (Reflow()) + { + DrawFrame(); + } + } + + // A key can arrive after a resize. Page keys must use the new boundaries, + // and accept/cancel must not leave the old, differently sized frame behind. + if (Reflow()) + { + DrawFrame(); + } + + if ((key.Modifiers & ConsoleModifiers.Control) != 0) + { + if (key.Key == ConsoleKey.C) + { + return null; + } + + if (key.Key is ConsoleKey.UpArrow or ConsoleKey.DownArrow) + { + scroll[cursor] = Math.Clamp( + scroll[cursor] + (key.Key == ConsoleKey.UpArrow ? -1 : 1), + 0, + layout.MaxScroll(cursor)); + continue; + } + } + + switch (key.Key) + { + case ConsoleKey.UpArrow: + cursor = (cursor - 1 + items.Count) % items.Count; + pageOffset = null; + break; + case ConsoleKey.DownArrow: + cursor = (cursor + 1) % items.Count; + pageOffset = null; + break; + case ConsoleKey.LeftArrow or ConsoleKey.PageUp: + MovePage(-1); + break; + case ConsoleKey.RightArrow or ConsoleKey.PageDown: + MovePage(1); + break; + case ConsoleKey.Home: + cursor = 0; + pageOffset = null; + break; + case ConsoleKey.End: + cursor = items.Count - 1; + pageOffset = null; + break; + case ConsoleKey.Spacebar: + if (!selected.Add(cursor)) + { + selected.Remove(cursor); + } + + break; + case ConsoleKey.A: + selected.UnionWith(Enumerable.Range(0, items.Count)); + break; + case ConsoleKey.C: + selected.Clear(); + break; + case ConsoleKey.Enter: + return items.Where((_, index) => selected.Contains(index)) + .Select(item => item.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + case ConsoleKey.Escape or ConsoleKey.Q: + return null; + } + } + } + finally + { + try + { + try + { + terminal.ResetStyle(); + } + finally + { + if (frameStarted) + { + var bottom = Math.Clamp(frameTop + height, 0, terminal.WindowHeight - 1); + terminal.SetCursorPosition(0, bottom); + if (bottom < terminal.WindowHeight - 1) + { + terminal.WriteLine(); + } + } + } + } + finally + { + try + { + screen?.Dispose(); + } + finally + { + terminal.RestoreState(original); + } + } + } + + PickerLayout Measure() + { + var size = terminal.GetWindowSize(); + return PickerLayout.For(items, title, mode, size.Width, size.Height, terminal.SupportsColor, note); + } + + void DrawFrame() + { + while (true) + { + Reflow(); + var previousHeight = height; + try + { + if (resetViewport) + { + terminal.ResetStyle(); + terminal.ClearViewport(); + frameTop = 0; + height = 0; + resetViewport = false; + } + + Render(items, selected, cursor, scroll, layout, frameTop, mode, ref height); + EnsureViewport(layout); + return; + } + catch (Exception ex) when (ex is ViewportChangedException || + (ex is IOException or ArgumentOutOfRangeException or InvalidOperationException) && ViewportChanged(layout)) + { + height = Math.Max(previousHeight, height); + resetViewport = true; + } + catch + { + // A failed redraw can leave the lower part of the previous frame intact. + height = Math.Max(previousHeight, height); + throw; + } + } + } + + bool Reflow() + { + if (!ViewportChanged(layout)) + { + return false; + } + + layout = Measure(); + resetViewport = true; + pageOffset = null; + for (var item = 0; item < scroll.Length; item++) + { + scroll[item] = Math.Min(scroll[item], layout.MaxScroll(item)); + } + + return true; + } + + void MovePage(int direction) + { + var page = layout.PageIndexFor(cursor); + var target = Math.Clamp(page + direction, 0, layout.Pages.Count - 1); + if (target == page) + { + return; + } + + pageOffset ??= cursor - layout.Pages[page].First; + var next = layout.Pages[target]; + cursor = next.First + Math.Min(pageOffset.Value, next.Count - 1); + } + } + + private void Render( + IReadOnlyList items, + HashSet selected, + int cursor, + int[] scroll, + PickerLayout layout, + int frameTop, + PickerMode mode, + ref int height) + { + var previousHeight = height; + height = 0; + var pageIndex = layout.PageIndexFor(cursor); + var page = layout.Pages[pageIndex]; + foreach (var line in layout.Header(pageIndex)) + { + WriteRow(layout, frameTop, ref height, new Span(line)); + } + + WriteRow(layout, frameTop, ref height); + for (var index = page.First; index < page.First + page.Count; index++) + { + var entry = layout.Entries[index]; + var isSelected = selected.Contains(index); + var rowStyle = index == cursor ? TerminalStyle.Focus : TerminalStyle.Default; + var offset = page.Scrollable ? scroll[index] : 0; + var rows = page.Scrollable ? page.VisibleRows : entry.Height; + // Continuations are wider than the space after the name, so scroll them below + // the fixed skill row rather than placing one into its narrower first-line slot. + WriteRow( + layout, frameTop, ref height, + new Span(index == cursor ? ">" : " ", rowStyle), + new Span(" ", rowStyle), + new Span("[", rowStyle), + new Span(isSelected ? "X" : " ", isSelected ? TerminalStyle.Selected : rowStyle), + new Span("]", rowStyle), + new Span($" {entry.Label}", rowStyle), + new Span($" - {entry.Description[0]}", rowStyle)); + for (var line = 1; line < rows; line++) + { + WriteRow(layout, frameTop, ref height, + new Span(new string(' ', layout.ContinuationColumn) + entry.Description[offset + line], rowStyle)); + } + } + + WriteRow(layout, frameTop, ref height); + foreach (var line in TerminalText.Wrap( + PickerLayout.Summary(selected.Count, items.Count, mode), layout.Width)) + { + WriteRow(layout, frameTop, ref height, new Span(line)); + } + + foreach (var line in layout.Help) + { + WriteRow(layout, frameTop, ref height, new Span(line, TerminalStyle.Muted)); + } + + if (page.Scrollable) + { + foreach (var line in TerminalText.Wrap( + PickerLayout.ScrollHelp(scroll[cursor] + 2, scroll[cursor] + page.VisibleRows, + layout.Entries[cursor].Height), + layout.Width)) + { + WriteRow(layout, frameTop, ref height, new Span(line, TerminalStyle.Muted)); + } + } + + // Erase old content, but park at the actual footer, not at the end of the erased + // rectangle. A short final page should not strand the eventual shell prompt. + var erased = height; + while (erased < previousHeight) + { + WriteRow(layout, frameTop, ref erased); + } + + terminal.SetCursorPosition(0, frameTop + height); + } + + private void WriteRow(PickerLayout layout, int frameTop, ref int height, params Span[] spans) + { + EnsureViewport(layout); + var row = height++; + terminal.SetCursorPosition(0, frameTop + row); + var cells = 0; + foreach (var span in spans) + { + terminal.SetStyle(layout.SupportsColor ? span.Style : TerminalStyle.Default); + EnsureViewport(layout); + terminal.Write(span.Text); + EnsureViewport(layout); + cells += TerminalText.Width(span.Text); + } + + terminal.ResetStyle(); + EnsureViewport(layout); + terminal.Write(new string(' ', layout.Width - cells)); + EnsureViewport(layout); + } + + private bool ViewportChanged(PickerLayout layout) + { + var size = terminal.GetWindowSize(); + return layout.WindowWidth != size.Width || layout.WindowHeight != size.Height || + layout.SupportsColor != terminal.SupportsColor; + } + + private void EnsureViewport(PickerLayout layout) + { + if (ViewportChanged(layout)) + { + throw new ViewportChangedException(); + } + } + + private readonly record struct Span(string Text, TerminalStyle Style = TerminalStyle.Default); + + private sealed class ViewportChangedException : Exception; +} diff --git a/dotnet-package-skills/src/Cli/TerminalText.cs b/dotnet-package-skills/src/Cli/TerminalText.cs new file mode 100644 index 0000000..ab12e14 --- /dev/null +++ b/dotnet-package-skills/src/Cli/TerminalText.cs @@ -0,0 +1,256 @@ +using System.Globalization; +using System.Text; + +namespace DotnetPackageSkills.Cli; + +/// Plain terminal text, measured and split at grapheme and display-cell boundaries. +internal static class TerminalText +{ + public static string Sanitize(string? text, bool multiline = false, bool trim = true) + { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + var clean = new StringBuilder(text.Length); + for (var index = 0; index < text.Length;) + { + var character = text[index]; + if (character is '\x1b' or '\x9b' or '\x9d' or '\x90' or '\x98' or '\x9e' or '\x9f') + { + index = SkipEscape(text, index); + continue; + } + + if (character is '\r' or '\n' or '\u2028' or '\u2029') + { + clean.Append(multiline ? '\n' : ' '); + index += character == '\r' && index + 1 < text.Length && text[index + 1] == '\n' ? 2 : 1; + continue; + } + + if (character == '\t') + { + clean.Append(' '); + index++; + continue; + } + + var status = Rune.DecodeFromUtf16(text.AsSpan(index), out var rune, out var consumed); + if (status != System.Buffers.OperationStatus.Done) + { + rune = Rune.ReplacementChar; + consumed = 1; + } + + index += consumed; + var category = Rune.GetUnicodeCategory(rune); + if (category == UnicodeCategory.Control || + category == UnicodeCategory.Format && rune.Value is not (0x200c or 0x200d or >= 0xe0020 and <= 0xe007f)) + { + continue; + } + + clean.Append(Rune.IsWhiteSpace(rune) ? " " : rune.ToString()); + } + + var result = clean.ToString(); + return trim ? result.Trim() : result; + } + + public static int Width(string text) => Elements(text).Sum(element => CellWidth(element)); + + public static IEnumerable Elements(string text) + { + var elements = StringInfo.GetTextElementEnumerator(text); + while (elements.MoveNext()) + { + yield return elements.GetTextElement(); + } + } + + public static int CellWidth(string element) + { + var width = 0; + var emojiPresentation = false; + foreach (var rune in element.EnumerateRunes()) + { + emojiPresentation |= rune.Value is 0xfe0f or 0x20e3; + if (Rune.GetUnicodeCategory(rune) is UnicodeCategory.NonSpacingMark or + UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark or + UnicodeCategory.Format or UnicodeCategory.Control) + { + continue; + } + + width = Math.Max(width, IsWide(rune.Value) ? 2 : 1); + } + + return width > 0 && emojiPresentation ? 2 : width; + } + + public static string PadRight(string text, int width) => + text + new string(' ', Math.Max(0, width - Width(text))); + + public static string Clip(string text, int width) + { + if (width <= 0) + { + return string.Empty; + } + + if (Width(text) <= width) + { + return text; + } + + var suffix = new string('.', Math.Min(3, width)); + var clipped = new StringBuilder(); + var available = width - suffix.Length; + foreach (var element in Elements(text)) + { + var cells = CellWidth(element); + if (cells > available) + { + break; + } + + clipped.Append(element); + available -= cells; + } + + return clipped.Append(suffix).ToString(); + } + + public static IReadOnlyList Wrap(string text, int width) => Wrap(text, width, width); + + public static IReadOnlyList Wrap(string text, int firstLineWidth, int continuationWidth) + { + ArgumentOutOfRangeException.ThrowIfLessThan(firstLineWidth, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(continuationWidth, 1); + var width = firstLineWidth; + var lines = new List(); + foreach (var paragraph in text.Split('\n')) + { + var line = new StringBuilder(); + var cells = 0; + foreach (var word in paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var wordWidth = Width(word); + if (cells > 0 && cells + 1 + wordWidth <= width) + { + line.Append(' ').Append(word); + cells += 1 + wordWidth; + continue; + } + + if (cells > 0) + { + lines.Add(line.ToString()); + line.Clear(); + cells = 0; + width = continuationWidth; + } + + foreach (var element in Elements(word)) + { + var elementWidth = CellWidth(element); + if (cells > 0 && cells + elementWidth > width) + { + lines.Add(line.ToString()); + line.Clear(); + cells = 0; + width = continuationWidth; + } + + if (elementWidth > width) + { + throw new ArgumentException("The column is narrower than a single display grapheme.", nameof(width)); + } + + line.Append(element); + cells += elementWidth; + } + } + + lines.Add(line.ToString()); + width = continuationWidth; + } + + return lines; + } + + private static int SkipEscape(string text, int index) + { + var kind = text[index++]; + if (kind == '\x1b') + { + if (index == text.Length) + { + return index; + } + + kind = text[index++]; + } + + if (kind is '[' or '\x9b') + { + while (index < text.Length) + { + if (text[index++] is >= '\x40' and <= '\x7e') + { + break; + } + } + } + else if (kind is ']' or 'P' or 'X' or '^' or '_' or '\x9d' or '\x90' or '\x98' or '\x9e' or '\x9f') + { + while (index < text.Length) + { + if (text[index++] is '\a' or '\x9c') + { + break; + } + + if (text[index - 1] == '\x1b' && index < text.Length && text[index] == '\\') + { + return index + 1; + } + } + } + else if (kind is >= '\x20' and <= '\x2f') + { + while (index < text.Length && text[index] is >= '\x20' and <= '\x2f') + { + index++; + } + + if (index < text.Length && text[index] is >= '\x30' and <= '\x7e') + { + index++; + } + } + + return index; + } + + private static bool IsWide(int value) => + value != 0x303f && value is >= 0x1100 and <= 0x115f or + 0x231a or 0x231b or 0x2329 or 0x232a or + >= 0x23e9 and <= 0x23ec or 0x23f0 or 0x23f3 or 0x25fd or 0x25fe or + 0x2614 or 0x2615 or >= 0x2648 and <= 0x2653 or 0x267f or 0x2693 or + 0x26a1 or 0x26aa or 0x26ab or 0x26bd or 0x26be or 0x26c4 or 0x26c5 or + 0x26ce or 0x26d4 or 0x26ea or 0x26f2 or 0x26f3 or 0x26f5 or 0x26fa or + 0x26fd or 0x2705 or 0x270a or 0x270b or 0x2728 or 0x274c or 0x274e or + >= 0x2753 and <= 0x2755 or 0x2757 or >= 0x2795 and <= 0x2797 or + 0x27b0 or 0x27bf or 0x2b1b or 0x2b1c or 0x2b50 or 0x2b55 or + >= 0x2e80 and <= 0xa4cf or >= 0xac00 and <= 0xd7a3 or + >= 0xf900 and <= 0xfaff or >= 0xfe10 and <= 0xfe19 or + >= 0xfe30 and <= 0xfe6f or >= 0xff00 and <= 0xff60 or + >= 0xffe0 and <= 0xffe6 or >= 0x16fe0 and <= 0x18dff or + >= 0x1aff0 and <= 0x1b2ff or 0x1f004 or 0x1f0cf or 0x1f18e or + >= 0x1f191 and <= 0x1f19a or >= 0x1f1e6 and <= 0x1f1ff or + >= 0x1f200 and <= 0x1f251 or >= 0x1f300 and <= 0x1faff or + >= 0x20000 and <= 0x3fffd; +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills.csproj b/dotnet-package-skills/src/DotnetPackageSkills.csproj new file mode 100644 index 0000000..a323a0e --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills.csproj @@ -0,0 +1,43 @@ + + + + Exe + net8.0;net10.0 + dotnet-package-skills + DotnetPackageSkills + + + Major + + + + true + true + true + + dotnet-package-skills + dotnet-package-skills + dotnet-package-skills contributors + Copies agent skills bundled inside NuGet packages out of the global packages folder and into a repository's skills directory, where coding agents can actually find them. + dotnet-tool;nuget;ai;agent;skills;claude;copilot + README.md + MIT + https://github.com/NuGet/Client.Tools/tree/main/dotnet-package-skills + https://github.com/NuGet/Client.Tools + git + true + + + + + + + + + + + + + diff --git a/dotnet-package-skills/src/Infrastructure/DotnetCli.cs b/dotnet-package-skills/src/Infrastructure/DotnetCli.cs new file mode 100644 index 0000000..bc0167c --- /dev/null +++ b/dotnet-package-skills/src/Infrastructure/DotnetCli.cs @@ -0,0 +1,20 @@ +namespace DotnetPackageSkills.Infrastructure; + +/// Invokes the dotnet CLI. +public sealed class DotnetCli(IProcessRunner runner) +{ + /// + /// The dotnet host to invoke. DOTNET_HOST_PATH is set by the SDK when the tool + /// runs inside a build or from another dotnet command, and points at the exact + /// host in use — preferring it avoids picking a different dotnet off PATH. + /// + private static string Executable => + Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") is { Length: > 0 } host && File.Exists(host) + ? host + : "dotnet"; + + public ProcessResult Run(params string[] arguments) => runner.Run(Executable, arguments); + + public ProcessResult Run(IReadOnlyList arguments, string? workingDirectory) => + runner.Run(Executable, arguments, workingDirectory); +} diff --git a/dotnet-package-skills/src/Infrastructure/ProcessRunner.cs b/dotnet-package-skills/src/Infrastructure/ProcessRunner.cs new file mode 100644 index 0000000..0bb5ca1 --- /dev/null +++ b/dotnet-package-skills/src/Infrastructure/ProcessRunner.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; + +namespace DotnetPackageSkills.Infrastructure; + +/// Result of running an external process to completion. +public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError) +{ + /// + /// Diagnostics text for error messages: tools write failures to stderr, but the + /// dotnet CLI frequently reports MSBuild and NuGet errors on stdout instead. + /// + public string Diagnostics => + string.IsNullOrWhiteSpace(StandardError) ? StandardOutput.Trim() : StandardError.Trim(); +} + +/// Runs external processes. Abstracted so command logic is testable without spawning dotnet. +public interface IProcessRunner +{ + ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null); +} + +/// Thrown when a process cannot be started or does not finish in time. +public sealed class ProcessExecutionException(string message, Exception? inner = null) + : Exception(message, inner); + +public sealed class ProcessRunner(TimeSpan? timeout = null) : IProcessRunner +{ + private readonly TimeSpan _timeout = timeout ?? TimeSpan.FromMinutes(5); + + public ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + if (!string.IsNullOrEmpty(workingDirectory)) + { + startInfo.WorkingDirectory = workingDirectory; + } + + using var process = new Process { StartInfo = startInfo }; + + try + { + process.Start(); + } + catch (Exception ex) + { + throw new ProcessExecutionException( + $"Could not start '{fileName}'. Make sure the .NET SDK is installed and on PATH " + + "(https://dotnet.microsoft.com/download).", ex); + } + + // Read both streams concurrently before waiting. Draining one to completion + // first deadlocks as soon as the other fills its pipe buffer, which dotnet + // restore output does routinely. + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit((int)_timeout.TotalMilliseconds)) + { + TryKill(process); + throw new ProcessExecutionException( + $"'{fileName} {string.Join(' ', arguments)}' did not finish within {_timeout.TotalSeconds:0} seconds."); + } + + // The overload that takes a timeout does not wait for the async output + // readers to drain, so the parameterless call is needed for complete output. + process.WaitForExit(); + + return new ProcessResult(process.ExitCode, standardOutput.Result, standardError.Result); + } + + private static void TryKill(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException or System.ComponentModel.Win32Exception) + { + // The process already exited or cannot be killed; nothing useful to do. + } + } +} diff --git a/dotnet-package-skills/src/NuGet/GlobalPackagesLocator.cs b/dotnet-package-skills/src/NuGet/GlobalPackagesLocator.cs new file mode 100644 index 0000000..e991b24 --- /dev/null +++ b/dotnet-package-skills/src/NuGet/GlobalPackagesLocator.cs @@ -0,0 +1,99 @@ +using DotnetPackageSkills.Infrastructure; + +namespace DotnetPackageSkills.NuGet; + +/// Locates the NuGet global packages folder, where restore extracts packages. +public sealed class GlobalPackagesLocator(DotnetCli dotnet) +{ + private const string Label = "global-packages:"; + + /// + /// Resolves the folder, honouring an explicit override, then NUGET_PACKAGES, then + /// whatever the CLI reports (which is the only thing that accounts for a + /// globalPackagesFolder set in a nuget.config). + /// + /// + /// Where to ask from. This matters: nuget.config discovery walks up from the current + /// directory, so asking from outside the repo silently ignores a repo-level config. + /// + public string Locate(string? overridePath, string workingDirectory) + { + if (!string.IsNullOrWhiteSpace(overridePath)) + { + var resolved = Path.GetFullPath(overridePath, workingDirectory); + return Directory.Exists(resolved) + ? resolved + : throw new PackageSkillsException($"--global-packages does not exist: {resolved}"); + } + + if (Environment.GetEnvironmentVariable("NUGET_PACKAGES") is { Length: > 0 } fromEnvironment && + Directory.Exists(fromEnvironment)) + { + return Path.GetFullPath(fromEnvironment); + } + + return FromCli(workingDirectory); + } + + private string FromCli(string workingDirectory) + { + var result = dotnet.Run(["nuget", "locals", "global-packages", "--list"], workingDirectory); + + if (result.ExitCode != 0) + { + throw new PackageSkillsException( + $""" + Could not determine the NuGet global packages folder. + 'dotnet nuget locals global-packages --list' failed with exit code {result.ExitCode}: + {result.Diagnostics} + """); + } + + var path = ParseListOutput(result.StandardOutput); + + if (path is null) + { + throw new PackageSkillsException( + $""" + Could not find the global packages path in the output of 'dotnet nuget locals global-packages --list': + {result.StandardOutput.Trim()} + """); + } + + if (!Directory.Exists(path)) + { + throw new PackageSkillsException( + $""" + NuGet reports its global packages folder as '{path}', but that directory does not exist. + Restore the project first — restore is what creates it. + """); + } + + return path; + } + + /// + /// Extracts the path from CLI output. The shape has drifted across SDK versions + /// ("global-packages: /path" today, "info : global-packages: /path" on older ones), + /// so this keys off the label rather than the line's position or prefix. + /// + internal static string? ParseListOutput(string output) + { + foreach (var line in output.Split('\n')) + { + var index = line.IndexOf(Label, StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + continue; + } + + var value = line[(index + Label.Length)..].Trim(); + if (value.Length > 0) + { + return Path.GetFullPath(value); + } + } + + return null; + } +} diff --git a/dotnet-package-skills/src/NuGet/PackageCoordinate.cs b/dotnet-package-skills/src/NuGet/PackageCoordinate.cs new file mode 100644 index 0000000..91767ce --- /dev/null +++ b/dotnet-package-skills/src/NuGet/PackageCoordinate.cs @@ -0,0 +1,104 @@ +using System.Text.RegularExpressions; + +namespace DotnetPackageSkills.NuGet; + +/// An exact package identity, written as Id@Version on the command line. +public sealed partial record PackageCoordinate(string Id, string Version) +{ + public const char Separator = '@'; + + /// + /// Parses Id@Version, rejecting anything that is not a single concrete version. + /// + /// + /// Version ranges and floating versions are refused rather than resolved. Resolving one + /// means picking a version, and the only correct answer to "which version" comes from a + /// project's restore — which is what --target is for. Guessing here would copy + /// skills that describe a version the user does not actually reference. + /// + public static PackageCoordinate Parse(string value) + { + var input = value?.Trim() ?? string.Empty; + + if (input.Length == 0) + { + throw new PackageSkillsException("--package needs a value in the form Id@Version, for example Mockly@1.10.0."); + } + + var separator = input.IndexOf(Separator); + + if (separator < 0) + { + throw new PackageSkillsException( + $"'{input}' is missing a version. Write --package as Id@Version, for example {input}@1.10.0. " + + "To take versions from a project instead, use --target."); + } + + if (input.IndexOf(Separator, separator + 1) >= 0) + { + throw new PackageSkillsException($"'{input}' has more than one '{Separator}'. Expected Id@Version."); + } + + var id = input[..separator].Trim(); + var version = input[(separator + 1)..].Trim(); + + if (id.Length == 0) + { + throw new PackageSkillsException($"'{input}' is missing a package id before the '{Separator}'."); + } + + ValidateId(id); + + if (version.Length == 0) + { + throw new PackageSkillsException($"'{input}' is missing a version after the '{Separator}'."); + } + + if (IsFloatingOrRange(version)) + { + throw new PackageSkillsException( + $""" + '{version}' is a floating version or a version range, and this tool needs an exact version. + Write it out, for example --package {id}@1.10.0. + To let restore choose the version, point at a project or solution with --target instead. + """); + } + + if (!ExactVersionPattern().IsMatch(version)) + { + throw new PackageSkillsException( + $"'{version}' is not a version this tool recognises. Expected something like 1.10.0 or 2.0.0-beta.1."); + } + + return new PackageCoordinate(id, version); + } + + internal static void ValidateId(string id) + { + if (!IsValidId(id)) + { + throw new PackageSkillsException( + $"'{id}' is not a valid package id. Ids are letters, digits and '_', joined by single '.' or '-' characters."); + } + } + + /// + /// NuGet's own rule for package ids, so any id that restore accepts is accepted here too, + /// including letters outside ASCII. + /// + internal static bool IsValidId(string id) => PackageIdPattern().IsMatch(id); + + /// Wildcards and NuGet interval notation: 4.*, [1.0,2.0), (,3.0]. + private static readonly char[] RangeCharacters = ['*', '[', ']', '(', ')', ',']; + + private static bool IsFloatingOrRange(string version) => version.IndexOfAny(RangeCharacters) >= 0; + + public override string ToString() => $"{Id}{Separator}{Version}"; + + // NuGet's PackageIdValidator pattern, with \z so a trailing newline can't end a match. + [GeneratedRegex(@"^\w+([.-]\w+)*\z", RegexOptions.CultureInvariant)] + private static partial Regex PackageIdPattern(); + + [GeneratedRegex(@"^\d+(\.\d+){0,3}(-[0-9A-Za-z][0-9A-Za-z.-]*)?(\+[0-9A-Za-z][0-9A-Za-z.-]*)?$")] + private static partial Regex ExactVersionPattern(); +} diff --git a/dotnet-package-skills/src/NuGet/PackageLister.cs b/dotnet-package-skills/src/NuGet/PackageLister.cs new file mode 100644 index 0000000..2a73fd5 --- /dev/null +++ b/dotnet-package-skills/src/NuGet/PackageLister.cs @@ -0,0 +1,214 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using DotnetPackageSkills.Infrastructure; + +namespace DotnetPackageSkills.NuGet; + +/// A package the target resolves to, after de-duplication across projects and frameworks. +public sealed record PackageReferenceInfo(string Id, string Version); + +/// +/// Lists the packages a solution or project resolves to, by way of +/// dotnet list <target> package --format json. +/// +public sealed class PackageLister(DotnetCli dotnet) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// + /// Runs dotnet list package as it is. Whether it restores is the SDK's call: the .NET 10 + /// SDK restores when it needs to, and earlier SDKs say the target has to be restored first. + /// + /// + /// This tool never restores. A failure is reported with what the SDK said, so the customer can + /// restore or fix whatever else it names, and then run the command again. + /// + public IReadOnlyList List(string target) + { + // The target goes *before* the `package` verb: `dotnet list package`. + var arguments = new List { "list", target, "package", "--format", "json" }; + var result = dotnet.Run(arguments, workingDirectory: Path.GetDirectoryName(target)); + + if (result.ExitCode != 0) + { + throw new PackageSkillsException( + $""" + 'dotnet list "{target}" package' failed with exit code {result.ExitCode}: + {ReportedProblems(result.StandardOutput) ?? result.Diagnostics} + + Resolve what it reports, for example by restoring the target, and then run this command again. + """); + } + + return Parse(result.StandardOutput); + } + + /// + /// The problems that a JSON listing reports, one per line, or null when there are none. The + /// .NET 10 SDK reports a failed restore this way, on standard output. + /// + private static string? ReportedProblems(string output) + { + var start = output.IndexOf('{'); + if (start < 0) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(output[start..]); + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("problems", out var problems) || + problems.ValueKind != JsonValueKind.Array) + { + return null; + } + + var lines = problems.EnumerateArray() + .Where(problem => problem.ValueKind == JsonValueKind.Object) + .Select(problem => (Level: Text(problem, "level"), Text: Text(problem, "text"))) + .Where(problem => !string.IsNullOrWhiteSpace(problem.Text)) + .Select(problem => string.IsNullOrWhiteSpace(problem.Level) + ? problem.Text! + : $"{problem.Level}: {problem.Text}") + .ToList(); + + return lines.Count == 0 ? null : string.Join(Environment.NewLine, lines); + } + catch (JsonException) + { + return null; + } + + static string? Text(JsonElement problem, string name) => + problem.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + internal static IReadOnlyList Parse(string json) + { + var report = Deserialize(json); + + // Key on (id, version) because each resolved version has its own folder in the global + // packages cache. Keeping all versions also lets skill discovery report name collisions. + var found = new Dictionary<(string Id, string Version), PackageReferenceInfo>(); + + foreach (var framework in report.Projects?.SelectMany(p => p.Frameworks ?? []) ?? []) + { + foreach (var entry in framework.TopLevelPackages ?? []) + { + var id = entry.Id?.Trim(); + + // The resolved version is what exists on disk: it is the concrete value behind a + // floating version or a version managed through Central Package Management. + var version = Coalesce(entry.ResolvedVersion, entry.RequestedVersion); + + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(version)) + { + continue; + } + + var key = (id.ToLowerInvariant(), version.ToLowerInvariant()); + found[key] = new PackageReferenceInfo(id, version); + } + } + + return [.. found.Values.OrderBy(p => p.Id, StringComparer.OrdinalIgnoreCase).ThenBy(p => p.Version, StringComparer.Ordinal)]; + + static string? Coalesce(string? first, string? second) => + string.IsNullOrWhiteSpace(first) ? second?.Trim() : first.Trim(); + } + + private static ListPackageReport Deserialize(string json) + { + // MSBuild sometimes writes warnings ahead of the payload, so fall back to the + // first '{' rather than assuming the whole stream is JSON. + foreach (var candidate in Candidates(json)) + { + try + { + var report = JsonSerializer.Deserialize(candidate, JsonOptions); + if (report is not null) + { + return report; + } + } + catch (JsonException) + { + // Try the next candidate. + } + } + + throw new PackageSkillsException( + $""" + Could not parse the output of 'dotnet list package --format json'. + + If the error above mentions an unrecognized '--format' option, the installed SDK predates 7.0.200 and needs upgrading. + Raw output: + {json.Trim()} + """); + + static IEnumerable Candidates(string text) + { + var trimmed = text.Trim(); + if (trimmed.Length == 0) + { + yield break; + } + + yield return trimmed; + + var start = trimmed.IndexOf('{'); + if (start > 0) + { + yield return trimmed[start..]; + } + } + } + + private sealed class ListPackageReport + { + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("projects")] + public List? Projects { get; set; } + } + + private sealed class ListPackageProject + { + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("frameworks")] + public List? Frameworks { get; set; } + } + + private sealed class ListPackageFramework + { + [JsonPropertyName("framework")] + public string? Framework { get; set; } + + [JsonPropertyName("topLevelPackages")] + public List? TopLevelPackages { get; set; } + } + + private sealed class ListPackageEntry + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("requestedVersion")] + public string? RequestedVersion { get; set; } + + [JsonPropertyName("resolvedVersion")] + public string? ResolvedVersion { get; set; } + } +} diff --git a/dotnet-package-skills/src/NuGet/PackagePathResolver.cs b/dotnet-package-skills/src/NuGet/PackagePathResolver.cs new file mode 100644 index 0000000..e0a826e --- /dev/null +++ b/dotnet-package-skills/src/NuGet/PackagePathResolver.cs @@ -0,0 +1,101 @@ +namespace DotnetPackageSkills.NuGet; + +/// +/// Maps a package id and version to its folder inside the global packages cache. +/// +/// +/// Restore extracts each package to <global-packages>/<id>/<version>/ +/// with both segments lowercased and the version normalized. This mirrors NuGet's own +/// normalization rules; where they are ambiguous, a directory scan settles it, so a +/// mismatch degrades into a slower lookup rather than a missed package. +/// +public static class PackagePathResolver +{ + /// Returns the extracted package folder, or null when it is not on disk. + public static string? Resolve(string globalPackagesFolder, string packageId, string version) + { + var packageDirectory = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant()); + + if (!Directory.Exists(packageDirectory)) + { + return null; + } + + var normalized = NormalizeVersion(version); + + var candidate = Path.Combine(packageDirectory, normalized); + if (Directory.Exists(candidate)) + { + return candidate; + } + + // Fall back to a case-insensitive scan. NuGet's normalization has corner cases + // (SemVer 2 build metadata, unusual padding) that are not worth reimplementing + // exactly, and the directory itself is the authoritative answer. + foreach (var directory in Directory.EnumerateDirectories(packageDirectory)) + { + var name = Path.GetFileName(directory); + if (name.Equals(normalized, StringComparison.OrdinalIgnoreCase) || + name.Equals(version, StringComparison.OrdinalIgnoreCase)) + { + return directory; + } + } + + return null; + } + + /// + /// Normalizes a version the way NuGet does for folder names: lowercased, build + /// metadata dropped, padded to three parts, and a fourth part dropped when zero. + /// So 1.2 becomes 1.2.0 and 1.2.3.0 becomes 1.2.3. + /// + public static string NormalizeVersion(string version) + { + var value = version.Trim(); + + // Build metadata is not part of package identity and never appears in the path. + var plus = value.IndexOf('+'); + if (plus >= 0) + { + value = value[..plus]; + } + + var dash = value.IndexOf('-'); + var core = dash >= 0 ? value[..dash] : value; + var prerelease = dash >= 0 ? value[(dash + 1)..] : string.Empty; + + var parts = core.Split('.'); + var numbers = new List(4); + + foreach (var part in parts) + { + if (!int.TryParse(part, out var number) || number < 0) + { + // Not a version shape this tool understands; leave it to the directory scan. + return version.Trim().ToLowerInvariant(); + } + + numbers.Add(number); + } + + while (numbers.Count < 3) + { + numbers.Add(0); + } + + if (numbers.Count >= 4 && numbers[3] == 0) + { + numbers.RemoveRange(3, numbers.Count - 3); + } + + var normalized = string.Join('.', numbers); + + if (prerelease.Length > 0) + { + normalized = $"{normalized}-{prerelease}"; + } + + return normalized.ToLowerInvariant(); + } +} diff --git a/dotnet-package-skills/src/NuGet/TargetLocator.cs b/dotnet-package-skills/src/NuGet/TargetLocator.cs new file mode 100644 index 0000000..1d6b649 --- /dev/null +++ b/dotnet-package-skills/src/NuGet/TargetLocator.cs @@ -0,0 +1,119 @@ +namespace DotnetPackageSkills.NuGet; + +/// Finds the solution or project to inspect when the user does not name one. +public static class TargetLocator +{ + private static readonly string[] SolutionExtensions = [".slnx", ".sln"]; + private static readonly string[] ProjectExtensions = [".csproj", ".fsproj", ".vbproj"]; + private static readonly string[] IgnoredDirectories = ["bin", "obj", ".git", "node_modules", "artifacts"]; + + /// + /// Resolves an explicit target, or auto-detects one under . + /// A directory is accepted and searched. + /// + public static string Resolve(string? requested, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(requested)) + { + return Detect(workingDirectory); + } + + var path = Path.GetFullPath(requested, workingDirectory); + + if (Directory.Exists(path)) + { + return Detect(path); + } + + if (!File.Exists(path)) + { + throw new PackageSkillsException($"--target does not exist: {path}"); + } + + var extension = Path.GetExtension(path); + if (!SolutionExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) && + !ProjectExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new PackageSkillsException( + $"--target must be a solution or project file, but got '{Path.GetFileName(path)}'. " + + "Supported extensions: .sln, .slnx, .csproj, .fsproj, .vbproj."); + } + + return path; + } + + /// + /// Searches for a target, preferring a solution over a project and the top level + /// over nested directories. A solution covers every project in one pass, which is + /// almost always what someone means by "my repo". + /// + public static string Detect(string directory) + { + if (!Directory.Exists(directory)) + { + throw new PackageSkillsException($"Directory does not exist: {directory}"); + } + + foreach (var extensions in new[] { SolutionExtensions, ProjectExtensions }) + { + var match = EnumerateFiles(directory, extensions, SearchOption.TopDirectoryOnly).FirstOrDefault(); + if (match is not null) + { + return match; + } + } + + foreach (var extensions in new[] { SolutionExtensions, ProjectExtensions }) + { + var match = EnumerateFiles(directory, extensions, SearchOption.AllDirectories) + .Where(path => !IsIgnored(path, directory)) + .FirstOrDefault(); + if (match is not null) + { + return match; + } + } + + throw new PackageSkillsException( + $"No solution or project found under {directory}. " + + "Pass one explicitly, for example: --target src/MyApp.sln"); + } + + private static IEnumerable EnumerateFiles(string directory, string[] extensions, SearchOption option) + { + IEnumerable files; + try + { + files = Directory.EnumerateFiles(directory, "*", option); + } + catch (UnauthorizedAccessException) + { + return []; + } + + // Rank by the extension's position in the list, so preference between formats + // (.slnx ahead of .sln) is not left to how the file names happen to sort. + return files + .Select(file => new + { + File = file, + Rank = Array.FindIndex( + extensions, + extension => extension.Equals(Path.GetExtension(file), StringComparison.OrdinalIgnoreCase)), + }) + .Where(candidate => candidate.Rank >= 0) + .OrderBy(candidate => candidate.Rank) + .ThenBy(candidate => candidate.File, StringComparer.Ordinal) + .Select(candidate => candidate.File); + } + + private static bool IsIgnored(string path, string root) + { + var relative = Path.GetRelativePath(root, path); + var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + // The file name itself is never a directory match. + return segments.Take(segments.Length - 1) + .Any(segment => IgnoredDirectories.Contains(segment, StringComparer.OrdinalIgnoreCase)); + } +} diff --git a/dotnet-package-skills/src/PackageSkillsException.cs b/dotnet-package-skills/src/PackageSkillsException.cs new file mode 100644 index 0000000..6106713 --- /dev/null +++ b/dotnet-package-skills/src/PackageSkillsException.cs @@ -0,0 +1,8 @@ +namespace DotnetPackageSkills; + +/// +/// A failure the user needs to act on. The message is printed verbatim without a +/// stack trace, so it must read as guidance rather than as a diagnostic. +/// +public sealed class PackageSkillsException(string message, Exception? inner = null) + : Exception(message, inner); diff --git a/dotnet-package-skills/src/Program.cs b/dotnet-package-skills/src/Program.cs new file mode 100644 index 0000000..21bb1fa --- /dev/null +++ b/dotnet-package-skills/src/Program.cs @@ -0,0 +1,394 @@ +using System.CommandLine; +using DotnetPackageSkills; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +return CommandLineBuilder.Invoke(args); + +namespace DotnetPackageSkills.Cli +{ + /// Wires up the command line surface. + internal static class CommandLineBuilder + { + /// + /// Vendor-neutral default. Agents that follow another convention are one + /// --destination away, which is why this is a default rather than a hard-coded path. + /// + private const string DefaultDestination = InstallRequest.DefaultDestination; + + public static int Invoke(string[] args, TextWriter? output = null, TextWriter? error = null) => + CommandLineDiagnostics.Invoke(Build().Parse(args), output ?? Console.Out, error ?? Console.Error); + + public static RootCommand Build() + { + var target = new Option("--target", "-t") + { + Description = "Solution or project to inspect. Defaults to searching the current directory.", + HelpName = "PATH", + }; + + var package = new Option("--package", "-p") + { + Description = + "Take skills from an exact package instead of a project, as Id@Version " + + "(for example Mockly@1.10.0). Repeatable. Floating versions are not accepted.", + HelpName = "ID@VERSION", + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = true, + }; + package.Validators.Add(result => + { + // Taking several values means the parser hands --package any unknown option that + // follows it, such as a --json left in an old script. No package ID starts with + // '-', so report it the way the parser reports an unknown option anywhere else. + foreach (var token in result.Tokens.Where(token => token.Value.StartsWith('-'))) + { + result.AddError($"Unrecognized command or argument '{token.Value}'."); + } + }); + + var destination = new Option("--destination", "-d") + { + Description = $"Folder to copy skills into. Default: {DefaultDestination}", + HelpName = "PATH", + DefaultValueFactory = _ => DefaultDestination, + }; + + var globalPackages = new Option("--global-packages") + { + Description = "Override the NuGet global packages folder instead of asking the CLI.", + HelpName = "PATH", + }; + + var dryRun = new Option("--dry-run") + { + Description = "Report what would change without writing anything.", + }; + + var interactive = new Option("--interactive", "-i") + { + Description = + "Choose which skills to add, with descriptions, one page at a time. " + + "Only skills that aren't installed are listed; installed skills are left as they are.", + }; + + var uninstallPackage = new Option("--package", "-p") + { + Description = + "Remove only this package's skills. Accepts Id, or Id@Version to remove them " + + "only if that version is the one installed.", + HelpName = "ID[@VERSION]", + Arity = ArgumentArity.ExactlyOne, + }; + uninstallPackage.Validators.Add(result => + { + if (result.IdentifierTokenCount > 1) + { + result.AddError("--package can be specified only once for uninstall."); + return; + } + + if (result.Tokens.Count == 1) + { + try + { + ParseUninstallFilter(result.Tokens[0].Value); + } + catch (PackageSkillsException error) + { + result.AddError(error.Message); + } + } + }); + + // Its own option rather than the one install uses, because "copy skills into" is + // nonsense on a command that only deletes. It still has to exist: skills installed + // to somewhere other than the default are unreachable without it. + var uninstallDestination = new Option("--destination", "-d") + { + Description = $"Folder to remove skills from. Default: {DefaultDestination}", + HelpName = "PATH", + DefaultValueFactory = _ => DefaultDestination, + }; + + var install = new Command("install", "Copy skills bundled in NuGet packages into the repository.") + { + target, package, destination, globalPackages, dryRun, interactive, + }; + install.Validators.Add(RejectTargetWithPackage); + install.SetAction(parseResult => Run(() => + { + var request = BuildRequest(parseResult); + var service = new SkillInstallService(new ProcessRunner()); + + var result = parseResult.GetValue(interactive) + ? InstallInteractively(service, request) + : service.Install(request); + + if (result is null) + { + new OutputWriter(Console.Out).WriteCancelled(); + return; + } + + new OutputWriter(Console.Out).WriteInstallReport(result, copied: true); + })); + + var list = new Command("list", "Show which packages ship skills, without copying anything.") + { + target, package, destination, globalPackages, + }; + list.Validators.Add(RejectTargetWithPackage); + list.SetAction(parseResult => Run(() => + { + var request = BuildRequest(parseResult) with { DryRun = true }; + var result = new SkillInstallService(new ProcessRunner()).Discover(request); + new OutputWriter(Console.Out).WriteInstallReport(result, copied: false); + })); + + var uninstallInteractive = new Option("--interactive", "-i") + { + Description = + "Choose which installed skills to remove, with descriptions, one page at a time. " + + "Only skills this tool installed are listed.", + }; + + var stale = new Option("--stale") + { + Description = + "Remove only stale skills: skills whose package the target no longer references, " + + "or references at a different version. Reads the target's package references, " + + "so it needs a solution or project.", + }; + + var staleTarget = new Option("--target", "-t") + { + Description = + "With --stale, the solution or project to compare against. " + + "Defaults to searching the current directory.", + HelpName = "PATH", + }; + + var uninstall = new Command("uninstall", "Remove skills this tool previously copied in.") + { + uninstallDestination, uninstallPackage, stale, staleTarget, dryRun, uninstallInteractive, + }; + uninstall.Validators.Add(result => + { + var isStale = result.GetResult(stale) is not null; + + if (isStale && result.GetResult(uninstallPackage) is not null) + { + result.AddError( + "--stale and --package cannot be combined. --stale removes the skills that no longer " + + "match the target; --package removes one package's skills."); + } + + if (!isStale && result.GetResult(staleTarget) is not null) + { + result.AddError("--target can be used with uninstall only together with --stale."); + } + }); + uninstall.SetAction(parseResult => Run(() => + { + var workingDirectory = Directory.GetCurrentDirectory(); + var destinationValue = parseResult.GetValue(uninstallDestination) ?? DefaultDestination; + var isDryRun = parseResult.GetValue(dryRun); + var (id, version) = ParseUninstallFilter(parseResult.GetValue(uninstallPackage)); + var root = Path.GetFullPath(destinationValue, workingDirectory); + var service = new SkillInstallService(new ProcessRunner()); + var references = parseResult.GetValue(stale) + ? service.ReadReferences(parseResult.GetValue(staleTarget), workingDirectory) + : null; + + UninstallChoice? choice = null; + + if (parseResult.GetValue(uninstallInteractive)) + { + choice = ChooseWhatToRemove(destinationValue, workingDirectory, id, version, references); + + if (choice is null) + { + new OutputWriter(Console.Out).WriteCancelled(); + return; + } + } + + var removed = service.Uninstall(destinationValue, workingDirectory, id, version, isDryRun, + choice?.Selected, choice?.ExpectedInstalled, references?.Packages); + + new OutputWriter(Console.Out).WriteUninstallReport(removed, root, isDryRun, references?.Target); + })); + + return new RootCommand( + """ + Copies agent skills bundled inside NuGet packages into a folder your coding agent reads. + + Package authors ship skills at skills/-/SKILL.md inside the package. Restore extracts them to the NuGet global packages folder, which is outside your repository and which no coding agent scans. This tool bridges that gap. + """) + { + install, list, uninstall, + }; + + InstallRequest BuildRequest(ParseResult parseResult) => new() + { + Target = parseResult.GetValue(target), + Packages = [.. (parseResult.GetValue(package) ?? []).Select(PackageCoordinate.Parse)], + Destination = parseResult.GetValue(destination) ?? DefaultDestination, + WorkingDirectory = Directory.GetCurrentDirectory(), + GlobalPackagesOverride = parseResult.GetValue(globalPackages), + DryRun = parseResult.GetValue(dryRun), + }; + + void RejectTargetWithPackage(System.CommandLine.Parsing.CommandResult result) + { + // Both would answer "which packages", and combining them hides which one won. + if (result.GetResult(target) is not null && result.GetResult(package) is not null) + { + result.AddError( + "--target and --package cannot be combined. Use --target to take versions " + + "from a project, or --package to name exact packages yourself."); + } + } + } + + /// + /// Discovers skills, lets the user pick from the ones not installed yet a page at a time, + /// then copies the picks. Returns null when the user cancelled. + /// + /// + /// Every check that could stop the install runs before the checklist opens, so a choice is + /// never made only to be refused. With nothing new to offer there is no checklist at all. + /// + private static InstallResult? InstallInteractively(SkillInstallService service, InstallRequest request) + { + var discovered = service.Discover(request); + var installed = SkillInstallService.InstalledSkills(discovered.Destination, request.WorkingDirectory); + var prepared = service.PrepareInteractiveInstall(request, discovered, installed); + var items = InteractiveSkills.ForInstall(prepared.Skills, installed); + + if (items.Count == 0) + { + return prepared with { NothingNewToInstall = prepared.SkillsDiscovered > 0 }; + } + + var picked = new SkillPicker(new SystemTerminal()) + .Choose(items, PickerTitle(discovered), PickerMode.Install, InstalledSkillsNote); + + if (picked is null) + { + return null; + } + + var choice = InteractiveSkills.InstallChoice(prepared.Skills, installed, items, picked); + + return service.Install(request, prepared, choice); + } + + /// + /// Offers the installed skills for removal and returns the ones ticked, or null when + /// the user cancelled. + /// + /// + /// The list comes from the manifest, so it holds exactly what this tool put there and + /// nothing a user wrote themselves. An empty list still returns an empty selection + /// rather than prompting, so the report can say there was nothing to remove. + /// + private static UninstallChoice? ChooseWhatToRemove( + string destination, + string workingDirectory, + string? packageId, + string? packageVersion, + TargetReferences? references) + { + var installed = SkillInstallService.InstalledSkills(destination, workingDirectory); + var matching = installed + .Where(entry => SkillInstaller.Matches(entry, packageId, packageVersion)) + .Where(entry => references is null || SkillInstaller.IsStale(entry, references.Packages)) + .ToList(); + + if (matching.Count == 0) + { + return new UninstallChoice([], installed); + } + + var items = InteractiveSkills.ForUninstall( + matching, + Path.GetFullPath(destination, workingDirectory)); + + var selected = new SkillPicker(new SystemTerminal()).Choose( + items, + "Which skills should be uninstalled?", + PickerMode.Uninstall, + references is null ? null : StaleSkillsNote); + return selected is null ? null : new UninstallChoice(selected.ToList(), installed); + } + + /// Shown under the install checklist title, because the list is not everything. + internal const string InstalledSkillsNote = "Installed skills aren't listed."; + + /// Shown under the uninstall checklist title with --stale. + internal const string StaleSkillsNote = "Only skills that don't match the target are listed."; + + private static string PickerTitle(InstallResult discovered) => + discovered.Target is null + ? "Which skills should be installed?" + : $"Which skills should be installed? ({Path.GetFileName(discovered.Target)})"; + + /// + /// Splits the uninstall filter, which unlike --package on install may omit the version + /// to mean "whichever version of this package is installed". + /// + internal static (string? Id, string? Version) ParseUninstallFilter(string? value) + { + if (value is null) + { + return (null, null); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new PackageSkillsException( + "--package requires a non-empty package ID, optionally followed by @Version. " + + "Omit --package only when you intend to remove all tracked skills."); + } + + if (!value.Contains(PackageCoordinate.Separator)) + { + var id = value.Trim(); + PackageCoordinate.ValidateId(id); + return (id, null); + } + + var coordinate = PackageCoordinate.Parse(value); + return (coordinate.Id, coordinate.Version); + } + + /// + /// Turns expected failures into a plain message and a non-zero exit code. Users of a CLI + /// should get guidance, not a stack trace, for anything we anticipated. + /// + private static int Run(Action action) + { + try + { + action(); + return 0; + } + catch (Exception ex) when (ex is PackageSkillsException or ProcessExecutionException) + { + new OutputWriter(Console.Out).WriteError(ex.Message); + return 1; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + new OutputWriter(Console.Out).WriteError( + $"{ex.Message}{Environment.NewLine}" + + "Check that the destination folder is writable and not open in another program."); + return 1; + } + } + } +} diff --git a/dotnet-package-skills/src/SkillInstallService.cs b/dotnet-package-skills/src/SkillInstallService.cs new file mode 100644 index 0000000..e1af1cd --- /dev/null +++ b/dotnet-package-skills/src/SkillInstallService.cs @@ -0,0 +1,505 @@ +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills; + +/// Inputs for an install or a list. +public sealed record InstallRequest +{ + /// Where skills go when no destination is given. + public const string DefaultDestination = ".agents/skills"; + + /// Solution or project to inspect. Ignored when is set. + public string? Target { get; init; } + + /// Exact packages to take skills from, instead of inspecting a project. + public IReadOnlyList Packages { get; init; } = []; + + public required string Destination { get; init; } + public required string WorkingDirectory { get; init; } + public string? GlobalPackagesOverride { get; init; } + public bool DryRun { get; init; } +} + +/// What an install or a list produced. +public sealed record InstallResult +{ + /// The solution or project inspected, or null when packages were named explicitly. + public string? Target { get; init; } + + public required string GlobalPackagesFolder { get; init; } + public required string Destination { get; init; } + public required int PackagesScanned { get; init; } + public required bool DryRun { get; init; } + public required IReadOnlyList Skills { get; init; } + + /// + /// How many skills discovery turned up, which stays put even after is + /// narrowed to what was actually installed. Without it a report cannot tell "no package ships + /// a skill" apart from "you chose none of the ones that do". + /// + public int SkillsDiscovered { get; init; } + + public IReadOnlyList Removed { get; init; } = []; + public IReadOnlyList Skipped { get; init; } = []; + + /// + /// Installed skills whose package the target no longer references. Install keeps them, and + /// the report points at uninstall --stale, the one command that removes them. + /// + public IReadOnlyList Unreferenced { get; init; } = []; + + /// + /// The command the report suggests for removing skills, spelled + /// with the target and destination this run used. + /// + public string StaleCommand { get; init; } = "dotnet-package-skills uninstall --stale"; + + /// + /// Set when an interactive install found skills but every one is installed already or + /// skipped, so there was nothing to choose from and no checklist was shown. + /// + public bool NothingNewToInstall { get; init; } + + internal IReadOnlyList ResolvedPackages { get; init; } = []; + internal IReadOnlyList? AllCandidates { get; init; } +} + +/// The skills the user checked. Installing a choice copies these and changes nothing else. +public sealed record SkillChoice(IReadOnlyList Selected) +{ + public IReadOnlyCollection? ExpectedInstalled { get; init; } +} + +/// Ties package listing, skill discovery, and installation together. +public sealed class SkillInstallService(DotnetCli dotnet, SkillInstaller installer) +{ + public SkillInstallService(IProcessRunner runner) : this(new DotnetCli(runner), new SkillInstaller()) + { + } + + /// Discovers bundled skills without writing anything. + public InstallResult Discover(InstallRequest request) + { + return request.Packages.Count > 0 + ? DiscoverFromCoordinates(request) + : DiscoverFromTarget(request); + } + + private InstallResult DiscoverFromTarget(InstallRequest request) + { + var target = TargetLocator.Resolve(request.Target, request.WorkingDirectory); + + // Ask for the global packages folder from the repository, not from wherever the user + // happened to invoke the tool: nuget.config discovery walks up from the current + // directory, and a repo-level config is exactly the case worth honouring. + var globalPackages = LocateGlobalPackages(request, Path.GetDirectoryName(target)); + + // Keep every distinct (id, version) long enough to detect unsupported multi-version + // collisions explicitly rather than silently selecting one package from the solution. + var packages = new PackageLister(dotnet).List(target); + + var (skills, skipped, candidates) = Collect(globalPackages, packages.Select(p => (p.Id, p.Version))); + + return Build(request, target, globalPackages, packages.Count, skills, skipped) + with { ResolvedPackages = packages, AllCandidates = candidates }; + } + + private InstallResult DiscoverFromCoordinates(InstallRequest request) + { + var globalPackages = LocateGlobalPackages(request, request.WorkingDirectory); + + var packages = request.Packages.DistinctBy(package => + (package.Id.ToLowerInvariant(), PackagePathResolver.NormalizeVersion(package.Version))).ToArray(); + var (skills, skipped, candidates) = Collect( + globalPackages, + packages.Select(coordinate => (coordinate.Id, coordinate.Version))); + + return Build(request, target: null, globalPackages, packages.Length, skills, skipped) + with + { + ResolvedPackages = [.. packages.Select(coordinate => new PackageReferenceInfo(coordinate.Id, coordinate.Version))], + AllCandidates = candidates, + }; + } + + private string LocateGlobalPackages(InstallRequest request, string? preferredDirectory) => + new GlobalPackagesLocator(dotnet).Locate( + request.GlobalPackagesOverride, + preferredDirectory ?? request.WorkingDirectory); + + /// + /// A package that is not in the cache contributes nothing, exactly like one that ships no + /// skills: getting packages into the cache is restore's job, not this tool's. Only a target + /// install checks for them, because it would otherwise report their skills as stale. + /// + private static (List Skills, List Skipped, List Candidates) Collect( + string globalPackages, + IEnumerable<(string Id, string Version)> packages) + { + var skills = new List(); + var skipped = new List(); + var candidates = new List(); + var destinations = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (id, version) in packages) + { + var packageDirectory = PackagePathResolver.Resolve(globalPackages, id, version); + + if (packageDirectory is null) + { + continue; + } + + foreach (var skill in SkillDiscovery.Discover(packageDirectory, id, version)) + { + candidates.Add(skill); + if (destinations.TryAdd(skill.RelativePath, skill)) + { + skills.Add(skill); + continue; + } + + var retained = destinations[skill.RelativePath]; + skipped.Add(ToSkipped( + skill, + $"conflicts with {retained.PackageId} {retained.PackageVersion} skill " + + $"'{retained.SkillName}', which was selected first")); + } + } + + return (skills, skipped, candidates); + } + + private static InstallResult Build( + InstallRequest request, + string? target, + string globalPackages, + int packagesScanned, + IReadOnlyList skills, + IReadOnlyList skipped) => + new() + { + Target = target, + GlobalPackagesFolder = globalPackages, + Destination = Path.GetFullPath(request.Destination, request.WorkingDirectory), + PackagesScanned = packagesScanned, + DryRun = request.DryRun, + Skills = skills, + SkillsDiscovered = skills.Count, + Skipped = skipped, + }; + + /// Discovers bundled skills and copies them into the destination. + public InstallResult Install(InstallRequest request) => Install(request, Discover(request), choice: null); + + /// + /// Copies a caller-chosen subset of already-discovered skills, which is what the interactive + /// picker produces. Passing a null installs everything discovered. + /// + public InstallResult Install(InstallRequest request, InstallResult discovered, SkillChoice? choice) + { + RequireOneVersionPerPackage(request, discovered); + + if (request.Packages.Count == 0) + { + RequireEveryPackageInCache(discovered); + } + + // A choice only adds: it never refreshes or removes what it was not asked about. Without + // one, the run covers the packages it found in the cache, so a version that is not there + // never causes a removal. + var offered = choice is null + ? discovered.ResolvedPackages + .Where(package => PackagePathResolver.Resolve(discovered.GlobalPackagesFolder, package.Id, package.Version) is not null) + .GroupBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Version, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + var outcome = installer.Install( + discovered.Destination, + choice?.Selected ?? discovered.AllCandidates ?? discovered.Skills, + request.DryRun, + offered, + choice?.ExpectedInstalled, + arguments => UninstallCommand(request, arguments)); + + return discovered with + { + DryRun = request.DryRun, + Skills = outcome.Installed, + Removed = outcome.Removed, + Skipped = discovered.AllCandidates is null + ? [.. discovered.Skipped, .. outcome.Skipped] + : outcome.Skipped, + // A target lists every package it references, so anything it did not offer has left + // the project. Named packages say nothing about the rest, so they report nothing. + Unreferenced = request.Packages.Count == 0 && choice is null ? outcome.Untouched : [], + StaleCommand = UninstallCommand(request, "--stale", withTarget: true), + AllCandidates = null, + }; + } + + /// + /// Stops an install when a package has more than one version, whether or not it ships skills. + /// + /// + /// The manifest records one version per package, and skills describe the version they came + /// from. With two versions there is no right answer for which guidance the repository gets, + /// so rather than guess, ask for the versions to be aligned. Central Package Management keeps + /// them aligned. does not check this, so list still shows both. + /// + private static void RequireOneVersionPerPackage(InstallRequest request, InstallResult discovered) + { + var conflicts = discovered.ResolvedPackages + .GroupBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => ( + group.First().Id, + Versions: group + .DistinctBy(package => PackagePathResolver.NormalizeVersion(package.Version)) + .Select(package => package.Version) + .ToList())) + .Where(entry => entry.Versions.Count > 1) + .Select(entry => $"{entry.Id} ({string.Join(", ", entry.Versions)})") + .ToList(); + + if (conflicts.Count == 0) + { + return; + } + + throw new PackageSkillsException(request.Packages.Count == 0 + ? "Cannot install skills because these packages resolve to more than one version: " + + $"{string.Join("; ", conflicts)}. Skills can come from only one version of each package. " + + "Align the versions, for example with Central Package Management, and then try again. " + + "No skills were changed." + : "Cannot install skills because --package names more than one version of these packages: " + + $"{string.Join("; ", conflicts)}. Skills can come from only one version of each package, " + + "so name one version per package, and then try again. No skills were changed."); + } + + /// + /// Stops a target install when a package the target references is not in the cache. + /// + /// + /// Restoring is not this tool's job, but carrying on would read an unextracted package as + /// one that ships nothing and report its installed skills as no longer referenced. Asking + /// for a restore is the honest answer. Named packages never reach this check: naming one + /// that is not in the cache simply finds no skills. + /// + private static void RequireEveryPackageInCache(InstallResult discovered) + { + var missing = discovered.ResolvedPackages + .Where(package => PackagePathResolver.Resolve(discovered.GlobalPackagesFolder, package.Id, package.Version) is null) + .Select(package => $"{package.Id} {package.Version}") + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (missing.Length > 0) + { + throw new PackageSkillsException( + $"Cannot install skills because resolved packages are missing from '{discovered.GlobalPackagesFolder}': " + + $"{string.Join(", ", missing)}. " + + "Run dotnet restore for the target using this cache, then try again. No skills were changed."); + } + } + + /// + /// Runs every check that stops an interactive install before a checklist opens, and returns + /// what it can offer: the skills that would install cleanly and are not installed yet. + /// + /// + /// An interactive install only adds, so it cannot settle a mismatch between the installed + /// skills and the packages. With a target, any stale skill stops it until + /// uninstall --stale removes them. With named packages, an installed skill from + /// another version of one of them stops it, because adding beside it would give that + /// package two versions. + /// + internal InstallResult PrepareInteractiveInstall( + InstallRequest request, + InstallResult discovered, + IReadOnlyCollection installed) + { + RequireOneVersionPerPackage(request, discovered); + + if (request.Packages.Count == 0) + { + RequireEveryPackageInCache(discovered); + RequireNoStaleSkills(request, discovered, installed); + } + else + { + RequireNoOtherInstalledVersion(request, discovered, installed); + } + + var preview = Install( + request with { DryRun = true }, + discovered, + new SkillChoice(discovered.AllCandidates ?? discovered.Skills) { ExpectedInstalled = installed }); + var tracked = installed.Select(entry => entry.Skill).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return preview with + { + DryRun = request.DryRun, + Skills = [.. preview.Skills.Where(skill => !tracked.Contains(skill.RelativePath))], + }; + } + + private static void RequireNoStaleSkills( + InstallRequest request, + InstallResult discovered, + IReadOnlyCollection installed) + { + var stale = installed + .Where(entry => SkillInstaller.IsStale(entry, discovered.ResolvedPackages)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + + if (stale.Count == 0) + { + return; + } + + throw new PackageSkillsException( + $"Cannot choose skills interactively because {stale.Count} installed " + + $"{(stale.Count == 1 ? "skill doesn't" : "skills don't")} match the target: " + + $"{string.Join(", ", stale.Select(entry => $"{entry.Skill} ({entry.Package} {entry.Version})"))}. " + + $"Run '{UninstallCommand(request, "--stale", withTarget: true)}' first, and then try again. " + + "No skills were changed."); + } + + private static void RequireNoOtherInstalledVersion( + InstallRequest request, + InstallResult discovered, + IReadOnlyCollection installed) + { + var conflicts = discovered.ResolvedPackages + .Select(package => ( + package.Id, + Installed: installed.FirstOrDefault(entry => + entry.Package.Equals(package.Id, StringComparison.OrdinalIgnoreCase) && + !SkillInstaller.SameVersion(entry.Version, package.Version)))) + .Where(conflict => conflict.Installed is not null) + .ToList(); + + if (conflicts.Count == 0) + { + return; + } + + var command = conflicts.Count == 1 + ? $"'{UninstallCommand(request, $"--package {conflicts[0].Id}")}'" + : $"'{UninstallCommand(request, "--package ")}' for each of them"; + throw new PackageSkillsException( + $"{string.Join(" and ", conflicts.Select(conflict => $"{conflict.Id} {conflict.Installed!.Version}"))} " + + $"{(conflicts.Count == 1 ? "is" : "are")} already installed, and an interactive install only adds " + + $"skills, so it can't change a package's version. Run {command} first, and then try again. " + + "No skills were changed."); + } + + /// Skill folder names the manifest in already tracks. + public static IReadOnlySet InstalledSkillNames(string destination) => + InstalledSkills(destination, Directory.GetCurrentDirectory()) + .Select(entry => entry.Skill) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Everything the manifest tracks, in the order a list should show it. + /// + /// + /// This is what uninstall offers to choose from. It reads the manifest rather than the + /// folder, so skills the user wrote themselves are never on the list — the same reason + /// removal is manifest-driven in the first place. + /// + public static IReadOnlyList InstalledSkills(string destination, string workingDirectory) + { + var root = Path.GetFullPath(destination, workingDirectory); + using var destinationLock = DestinationLock.Acquire(root); + return + [ + .. InstallManifest.Load(root) + .EnumerateSkills() + .OrderBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase) + .ThenBy(entry => entry.Skill, StringComparer.Ordinal), + ]; + } + + /// + /// Removes skills this tool installed, optionally limited to one package, one exact + /// version, the names the caller chose, or the skills that are stale against a target. + /// + public IReadOnlyList Uninstall( + string destination, + string workingDirectory, + string? packageId, + string? packageVersion, + bool dryRun, + IReadOnlyCollection? only = null, + IReadOnlyCollection? expectedInstalled = null, + IReadOnlyCollection? staleAgainst = null) + { + var root = Path.GetFullPath(destination, workingDirectory); + return installer.Uninstall(root, packageId, packageVersion, dryRun, only, expectedInstalled, staleAgainst); + } + + /// + /// Finds the target and lists its direct package references with dotnet list package. + /// This is all uninstall --stale reads: deciding which skills are stale needs the + /// references, not the packages, so the tool never looks in the NuGet cache for them. + /// + public TargetReferences ReadReferences(string? target, string workingDirectory) + { + var resolved = TargetLocator.Resolve(target, workingDirectory); + return new TargetReferences(resolved, new PackageLister(dotnet).List(resolved)); + } + + private static SkippedSkill ToSkipped(BundledSkill skill, string reason) => + new( + skill.RelativePath, + skill.PackageId, + skill.PackageVersion, + skill.SkillName, + reason); + + /// + /// Spells an uninstall command that a report or an error suggests, with the target and the + /// destination this run used. + /// + /// Repeat --target, which only uninstall --stale accepts. + internal static string UninstallCommand(InstallRequest request, string arguments, bool withTarget = false) + { + // A suggestion is only useful if running it as printed acts on the same skills folder, + // compared against the same project. + var command = $"dotnet-package-skills uninstall {arguments}"; + + if (withTarget && request.Target is not null) + { + command += $" --target {CommandArgument(request.Target)}"; + } + + if (!IsDefaultDestination(request)) + { + command += $" --destination {CommandArgument(request.Destination)}"; + } + + return command; + } + + private static bool IsDefaultDestination(InstallRequest request) => + Path.TrimEndingDirectorySeparator(Path.GetFullPath(request.Destination, request.WorkingDirectory)).Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(InstallRequest.DefaultDestination, request.WorkingDirectory)), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + /// + /// Quotes a value that a shell would otherwise split or reinterpret. Backslashes count: + /// bash treats them as escapes outside quotes, and every shell reads them literally inside. + /// + private static string CommandArgument(string value) => + value.Length > 0 && value.All(character => char.IsAsciiLetterOrDigit(character) || "._-/:+@".Contains(character)) + ? value + : $"\"{value}\""; +} + +/// A solution or project and the package versions it references directly. +public sealed record TargetReferences(string Target, IReadOnlyList Packages); diff --git a/dotnet-package-skills/src/Skills/BundledSkill.cs b/dotnet-package-skills/src/Skills/BundledSkill.cs new file mode 100644 index 0000000..0cdff99 --- /dev/null +++ b/dotnet-package-skills/src/Skills/BundledSkill.cs @@ -0,0 +1,25 @@ +namespace DotnetPackageSkills.Skills; + +/// A skill found inside an extracted NuGet package. +/// Package id as reported by NuGet, in its original casing. +/// Resolved version as reported by NuGet. +/// Folder name of the skill inside the package's skills/ directory. +/// Absolute path to the skill folder in the global packages cache. +/// +/// Destination path relative to the skills root, always with forward slashes so the manifest is +/// stable across operating systems. +/// +public sealed record BundledSkill( + string PackageId, + string PackageVersion, + string SkillName, + string SourcePath, + string RelativePath); + +/// A package skill that was not copied because its destination path collided. +public sealed record SkippedSkill( + string RelativePath, + string PackageId, + string PackageVersion, + string SkillName, + string Reason); diff --git a/dotnet-package-skills/src/Skills/DestinationLock.cs b/dotnet-package-skills/src/Skills/DestinationLock.cs new file mode 100644 index 0000000..9f74773 --- /dev/null +++ b/dotnet-package-skills/src/Skills/DestinationLock.cs @@ -0,0 +1,193 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace DotnetPackageSkills.Skills; + +/// Serializes cooperating tool processes from ownership checks through manifest persistence. +internal sealed class DestinationLock : IDisposable +{ + private readonly Mutex _mutex; + private bool _disposed; + + private DestinationLock(Mutex mutex) => _mutex = mutex; + + public static DestinationLock Acquire(string destination, TimeSpan? timeout = null) + { + var name = NameFor(destination); + var mutex = new Mutex(initiallyOwned: false, name); + var acquired = false; + try + { + try + { + acquired = mutex.WaitOne(timeout ?? TimeSpan.FromSeconds(30)); + } + catch (AbandonedMutexException error) + { + acquired = true; + throw new PackageSkillsException( + $"A previous operation on '{destination}' was interrupted. " + + "Check the destination and its manifest before trying again; no changes were made by this operation.", + error); + } + + if (!acquired) + { + throw new PackageSkillsException( + $"Another operation is using the skills destination '{destination}'. " + + "Wait for it to finish and try again. No skills were changed."); + } + + if (!name.Equals(NameFor(destination), StringComparison.Ordinal)) + { + throw new PackageSkillsException( + $"The skills destination '{destination}' changed while waiting for another operation. " + + "Run the command again to review its current location. No skills were changed."); + } + + return new DestinationLock(mutex); + } + catch + { + if (acquired) + { + mutex.ReleaseMutex(); + } + + mutex.Dispose(); + throw; + } + } + + internal static string NameFor(string destination) + { + var full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(destination)); + if (OperatingSystem.IsWindows()) + { + return MutexName(CanonicalWindowsPath(full).ToUpperInvariant(), windows: true); + } + + var root = Path.GetPathRoot(full)!; + var canonical = root; + foreach (var part in full[root.Length..].Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + canonical = Path.Combine(canonical, part); + var directory = new DirectoryInfo(canonical); + if (directory.Exists && directory.LinkTarget is not null) + { + canonical = directory.ResolveLinkTarget(returnFinalTarget: true)?.FullName + ?? throw new PackageSkillsException($"Could not resolve the skills destination '{destination}'."); + } + } + + canonical = Path.TrimEndingDirectorySeparator(canonical); + return MutexName(canonical, windows: false); + } + + private static string MutexName(string canonical, bool windows) + { + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); + return (windows ? @"Global\" : string.Empty) + "dotnet-package-skills-" + hash; + } + + [SupportedOSPlatform("windows")] + private static string CanonicalWindowsPath(string full) + { + var missing = new Stack(); + var existing = new DirectoryInfo(full); + while (!existing.Exists) + { + missing.Push(existing.Name); + existing = existing.Parent + ?? throw new PackageSkillsException($"Could not resolve the skills destination '{full}'."); + } + + // The OS resolves device prefixes, short names, drive mappings, and junctions to + // the same path. Resolve only the existing parent so previews create no directories. + var openPath = existing.FullName; + if (!openPath.StartsWith(@"\\?\", StringComparison.Ordinal) && + !openPath.StartsWith(@"\\.\", StringComparison.Ordinal)) + { + openPath = openPath.StartsWith(@"\\", StringComparison.Ordinal) + ? @"\\?\UNC\" + openPath[2..] + : @"\\?\" + openPath; + } + + using var handle = CreateFile( + openPath, 0, 7, nint.Zero, 3, 0x02000000, nint.Zero); + if (handle.IsInvalid) + { + throw PathError(full); + } + + var path = new StringBuilder(256); + var length = GetFinalPathNameByHandle(handle, path, (uint)path.Capacity, 0); + if (length == 0) + { + throw PathError(full); + } + + if (length >= path.Capacity) + { + path = new StringBuilder(checked((int)length + 1)); + length = GetFinalPathNameByHandle(handle, path, (uint)path.Capacity, 0); + if (length == 0 || length >= path.Capacity) + { + throw PathError(full); + } + } + + var canonical = path.ToString(); + if (canonical.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) + { + canonical = @"\\" + canonical[8..]; + } + else if (canonical.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) + { + canonical = canonical[4..]; + } + + foreach (var component in missing) + { + canonical = Path.Combine(canonical, component); + } + + return Path.TrimEndingDirectorySeparator(canonical); + } + + private static IOException PathError(string path) => new( + $"Could not resolve the skills destination '{path}'.", + new Win32Exception(Marshal.GetLastPInvokeError())); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, nint security, uint disposition, uint flags, nint template); + + [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle handle, StringBuilder path, uint size, uint flags); + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + try + { + _mutex.ReleaseMutex(); + } + finally + { + _mutex.Dispose(); + } + } +} diff --git a/dotnet-package-skills/src/Skills/InstallManifest.cs b/dotnet-package-skills/src/Skills/InstallManifest.cs new file mode 100644 index 0000000..5f25df8 --- /dev/null +++ b/dotnet-package-skills/src/Skills/InstallManifest.cs @@ -0,0 +1,348 @@ +using System.Text; +using System.Text.Json; +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Skills; + +/// One installed skill with its owning package metadata. +public sealed record TrackedSkill(string Package, string Version, string Skill); + +/// The skills this tool installed from one package, and the one version they came from. +public sealed record ManifestPackage(string Version, IReadOnlyList Skills); + +/// +/// Record of what this tool put in the destination folder. +/// +/// +/// The manifest is what makes removal safe. Refreshing, removal and uninstall act only on skill +/// folder names recorded under their owning package, never on whatever happens to be in the +/// destination, so hand-authored skills living alongside package-provided ones are never at risk. +/// +/// The layout follows dotnet-tools.json: a format version, then a packages map +/// keyed by lowercase package id, each with the one version its skills came from. Repositories +/// commit this file, so the bytes are deterministic: sorted, normalized, and LF-only on every +/// operating system. +/// +public sealed class InstallManifest +{ + public const string FileName = ".dotnet-package-skills.json"; + + /// + /// The only format this build reads and writes. A newer tool that changes the format raises + /// this number, and this build then refuses the file rather than dropping what it can't read. + /// + public const int FormatVersion = 1; + + private SortedDictionary _packages = new(StringComparer.Ordinal); + + /// Tracked packages by lowercase id. + public IReadOnlyDictionary Packages => _packages; + + internal bool IsEmpty => _packages.Count == 0; + + /// Loads and validates the manifest without changing it. + /// + /// An unreadable manifest cannot safely mean "nothing is tracked." Doing that makes every + /// folder this tool installed look user-owned, so install refuses to update it and uninstall + /// refuses to remove it. Stop instead: ownership is unknown, and guessing could overwrite or + /// delete a hand-authored skill. + /// + public static InstallManifest Load(string destinationRoot) + { + var path = Path.Combine(destinationRoot, FileName); + + if (!File.Exists(path)) + { + return new InstallManifest(); + } + + string text; + try + { + text = File.ReadAllText(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw CannotRead(path, "the file could not be opened", ex); + } + + JsonDocument document; + try + { + document = JsonDocument.Parse(text); + } + catch (JsonException ex) + { + throw CannotRead(path, "it does not contain valid manifest JSON", ex); + } + + using (document) + { + return Read(document.RootElement, path); + } + } + + private static InstallManifest Read(JsonElement root, string path) + { + if (root.ValueKind != JsonValueKind.Object) + { + throw CannotRead(path, "it must contain a JSON object"); + } + + if (FindDuplicateProperty(root) is { } duplicate) + { + throw CannotRead(path, $"the property '{duplicate}' appears more than once"); + } + + if (!TryGetProperty(root, "version", out var formatVersion)) + { + throw TryGetProperty(root, "installed", out _) && !TryGetProperty(root, "packages", out _) + ? WrittenByPreRelease(path) + : CannotRead(path, "it has no format 'version'"); + } + + if (formatVersion.ValueKind != JsonValueKind.Number || !formatVersion.TryGetInt32(out var number)) + { + throw CannotRead(path, "its format 'version' must be a whole number"); + } + + if (number > FormatVersion) + { + throw WrittenByNewerTool(path, number); + } + + if (number < 1) + { + throw CannotRead(path, $"format version {number} is not supported"); + } + + if (!TryGetProperty(root, "packages", out var packages) || packages.ValueKind != JsonValueKind.Object) + { + throw CannotRead(path, "'packages' must be an object"); + } + + var manifest = new InstallManifest(); + var claimed = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var package in packages.EnumerateObject()) + { + var id = package.Name; + + if (!PackageCoordinate.IsValidId(id)) + { + throw CannotRead(path, $"'{id}' is not a valid package id"); + } + + if (package.Value.ValueKind != JsonValueKind.Object) + { + throw CannotRead(path, $"'packages.{id}' must be an object"); + } + + if (!TryGetProperty(package.Value, "version", out var version) || + version.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(version.GetString())) + { + throw CannotRead(path, $"'packages.{id}.version' must be text"); + } + + if (!TryGetProperty(package.Value, "skills", out var skills) || skills.ValueKind != JsonValueKind.Array) + { + throw CannotRead(path, $"'packages.{id}.skills' must be an array"); + } + + var names = new List(); + foreach (var skill in skills.EnumerateArray()) + { + var name = skill.ValueKind == JsonValueKind.String ? skill.GetString() : null; + + if (name is null || !SkillDiscovery.IsSafeSkillName(name)) + { + throw CannotRead( + path, + $"'packages.{id}.skills[{names.Count}]' is not a safe skill folder name"); + } + + if (!claimed.Add(name)) + { + throw CannotRead(path, $"the skill folder '{name}' is claimed more than once"); + } + + names.Add(name); + } + + manifest._packages[id.ToLowerInvariant()] = new ManifestPackage(version.GetString()!, names); + } + + return manifest; + } + + /// Names are matched without regard to case, as they were in every earlier build. + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + foreach (var property in element.EnumerateObject()) + { + if (property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + + value = default; + return false; + } + + private static string? FindDuplicateProperty(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + return property.Name; + } + + if (FindDuplicateProperty(property.Value) is { } nested) + { + return nested; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + if (FindDuplicateProperty(item) is { } nested) + { + return nested; + } + } + } + + return null; + } + + private static PackageSkillsException CannotRead( + string path, + string reason, + Exception? inner = null) => + new( + $"Could not read the install manifest '{path}' because {reason}. " + + "No skills were changed and the file was preserved. Resolve any merge conflict or " + + "restore the file, then try again. If it cannot be recovered, move the destination " + + "folder aside before reinstalling.", + inner); + + private static PackageSkillsException WrittenByNewerTool(string path, int formatVersion) => + new( + $"Could not read the install manifest '{path}' because it uses format version {formatVersion}, " + + $"and this version of dotnet-package-skills supports only version {FormatVersion}. " + + "No skills were changed and the file was preserved. Update dotnet-package-skills, and then try again."); + + private static PackageSkillsException WrittenByPreRelease(string path) => + new( + $"Could not read the install manifest '{path}' because it was written by a pre-release version " + + "of dotnet-package-skills. No skills were changed and the file was preserved. " + + "Move the skills folder aside, and then run install again."); + + public void Save(string destinationRoot) + { + Directory.CreateDirectory(destinationRoot); + + // Rewrite the existing file rather than replacing it, which keeps its permissions. + File.WriteAllText( + Path.Combine(destinationRoot, FileName), + Serialize(), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + private string Serialize() + { + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteNumber("version", FormatVersion); + writer.WriteStartObject("packages"); + + foreach (var (id, package) in _packages) + { + writer.WriteStartObject(id); + writer.WriteString("version", package.Version); + writer.WriteStartArray("skills"); + + foreach (var skill in package.Skills) + { + writer.WriteStringValue(skill); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + // .NET 8 indents with the platform newline. Values are escaped, so every CRLF here is + // the writer's own, and replacing them makes a Windows file identical to a Linux one. + return Encoding.UTF8.GetString(buffer.ToArray()).Replace("\r\n", "\n") + "\n"; + } + + internal IEnumerable EnumerateSkills() => + _packages.SelectMany(package => + package.Value.Skills.Select(skill => new TrackedSkill(package.Key, package.Value.Version, skill))); + + /// Replaces everything tracked. Nothing is kept if the skills break a manifest rule. + internal void SetSkills(IEnumerable skills) + { + var next = new SortedDictionary(StringComparer.Ordinal); + + foreach (var group in skills.GroupBy(skill => skill.Package.ToLowerInvariant(), StringComparer.Ordinal)) + { + // The reader refuses an invalid id, so writing one would lock every later command + // out of the destination. + if (!PackageCoordinate.IsValidId(group.Key)) + { + throw new PackageSkillsException( + $"The install manifest can't record the package '{group.Key}' because it is not a valid package id. " + + "No skills were changed."); + } + + var versions = group + .Select(skill => PackagePathResolver.NormalizeVersion(skill.Version)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (versions.Count > 1) + { + throw new PackageSkillsException( + $"The install manifest can record only one version of each package, but skills from " + + $"{group.Key} {string.Join(" and ", versions)} were about to be recorded. " + + "No skills were changed."); + } + + next[group.Key] = new ManifestPackage( + versions[0], + [ + .. group + .Select(skill => skill.Skill) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(skill => skill, StringComparer.Ordinal), + ]); + } + + _packages = next; + } + + public static void Delete(string destinationRoot) + { + var path = Path.Combine(destinationRoot, FileName); + if (File.Exists(path)) + { + File.Delete(path); + } + } +} diff --git a/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs b/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs new file mode 100644 index 0000000..e093b72 --- /dev/null +++ b/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs @@ -0,0 +1,241 @@ +using System.Text; +using SharpYaml; +using SharpYaml.Events; + +namespace DotnetPackageSkills.Skills; + +internal sealed record SkillDescriptionResult(string? Description, string? Warning); + +internal static class SkillDescriptionReader +{ + private const int MaxFrontmatterCharacters = 64 * 1024; + private const int MaxNestingDepth = 32; + + public static SkillDescriptionResult Read(string skillDirectory) + { + try + { + using var reader = new StreamReader(Path.Combine(skillDirectory, SkillDiscovery.SkillManifestFileName)); + var charactersRead = 0; + if (!ReadOpeningDelimiter(reader, ref charactersRead)) + { + return new(null, null); + } + + var frontmatter = new StringBuilder(); + while (ReadBoundedLine(reader, ref charactersRead) is { } line) + { + if (line.TrimEnd(' ', '\t') is "---" or "...") + { + return Parse(frontmatter.ToString()); + } + + frontmatter.Append(line).Append('\n'); + } + + return Warn("frontmatter has no closing delimiter; add a closing '---' line."); + } + catch (FileNotFoundException) + { + return Warn("was not found; restore the file to read its description."); + } + catch (DirectoryNotFoundException) + { + return Warn("was not found; restore the file to read its description."); + } + catch (UnauthorizedAccessException) + { + return Warn("could not be read; check file permissions."); + } + catch (IOException) + { + return Warn("could not be read; check the file path, permissions, and whether it is in use."); + } + catch (ArgumentException) + { + return Warn("has an invalid path; check the skill directory path."); + } + catch (YamlException) + { + return Warn("has malformed YAML frontmatter; fix the header."); + } + catch (FrontmatterException exception) + { + return Warn(exception.Message); + } + } + + private static bool ReadOpeningDelimiter(StreamReader reader, ref int charactersRead) + { + for (var index = 0; index < 3; index++) + { + if (ReadCharacter(reader, ref charactersRead) != '-') + { + return false; + } + } + + while (true) + { + switch (ReadCharacter(reader, ref charactersRead)) + { + case -1: + case '\n': + return true; + case '\r': + ReadLineFeed(reader, ref charactersRead); + return true; + case ' ': + case '\t': + break; + default: + return false; + } + } + } + + private static string? ReadBoundedLine(StreamReader reader, ref int charactersRead) + { + var line = new StringBuilder(); + while (true) + { + var character = ReadCharacter(reader, ref charactersRead); + switch (character) + { + case -1: + return line.Length == 0 ? null : line.ToString(); + case '\n': + return line.ToString(); + case '\r': + ReadLineFeed(reader, ref charactersRead); + return line.ToString(); + default: + line.Append((char)character); + break; + } + } + } + + private static void ReadLineFeed(StreamReader reader, ref int charactersRead) + { + if (reader.Peek() == '\n') + { + ReadCharacter(reader, ref charactersRead); + } + } + + private static int ReadCharacter(StreamReader reader, ref int charactersRead) + { + var character = reader.Read(); + if (character >= 0 && ++charactersRead > MaxFrontmatterCharacters) + { + throw new FrontmatterException("frontmatter exceeds 64 KiB of text; shorten the header."); + } + + return character; + } + + private static SkillDescriptionResult Parse(string frontmatter) + { + var parser = new EventReader(Parser.CreateParser(new StringReader(frontmatter))); + parser.Expect(); + if (parser.Allow() is not null) + { + return new(null, null); + } + + parser.Expect(); + var root = ReadNodeStart(parser); + if (root is not MappingStart) + { + throw new FrontmatterException("frontmatter must be a YAML mapping; use 'description: ...'."); + } + + string? description = null; + var foundDescription = false; + while (parser.Allow() is null) + { + var key = ReadNode(parser, 2); + var value = ReadNode(parser, 2); + if (key?.Value != "description") + { + continue; + } + + if (foundDescription) + { + throw new FrontmatterException("has duplicate description keys; keep only one top-level description."); + } + + foundDescription = true; + if (value is null) + { + throw new FrontmatterException("description must be a YAML scalar; replace the collection with text."); + } + + description = string.IsNullOrWhiteSpace(value.Value) ? null : value.Value; + } + + parser.Expect(); + parser.Expect(); + return new(description, null); + } + + private static Scalar? ReadNode(EventReader parser, int depth) + { + var node = ReadNodeStart(parser); + if (node is Scalar scalar) + { + return scalar; + } + + if (depth > MaxNestingDepth) + { + throw new FrontmatterException("frontmatter exceeds 32 levels of nesting; simplify the header."); + } + + if (node is MappingStart) + { + while (parser.Allow() is null) + { + ReadNode(parser, depth + 1); + ReadNode(parser, depth + 1); + } + } + else + { + while (parser.Allow() is null) + { + ReadNode(parser, depth + 1); + } + } + + return null; + } + + private static NodeEvent ReadNodeStart(EventReader parser) + { + if (parser.Accept()) + { + throw new FrontmatterException("uses YAML anchors or aliases; replace them with literal values."); + } + + var node = parser.Expect(); + if (!string.IsNullOrEmpty(node.Anchor)) + { + throw new FrontmatterException("uses YAML anchors or aliases; replace them with literal values."); + } + + if (!string.IsNullOrEmpty(node.Tag)) + { + throw new FrontmatterException("uses explicit YAML tags; remove the tags from its frontmatter."); + } + + return node; + } + + private static SkillDescriptionResult Warn(string reason) => + new(null, $"{SkillDiscovery.SkillManifestFileName} {reason}"); + + private sealed class FrontmatterException(string message) : Exception(message); +} diff --git a/dotnet-package-skills/src/Skills/SkillDiscovery.cs b/dotnet-package-skills/src/Skills/SkillDiscovery.cs new file mode 100644 index 0000000..28b8edc --- /dev/null +++ b/dotnet-package-skills/src/Skills/SkillDiscovery.cs @@ -0,0 +1,75 @@ +namespace DotnetPackageSkills.Skills; + +/// Finds skills that a package author bundled at skills/ in the package root. +public static class SkillDiscovery +{ + public const string SkillsFolderName = "skills"; + public const string SkillManifestFileName = "SKILL.md"; + + /// + /// Enumerates the skills inside an extracted package. + /// + /// + /// Each immediate subdirectory of skills/ that contains SKILL.md is one skill, + /// and the whole directory is copied as-is. Its authored folder name is also its destination + /// folder name. Nothing inside the skill is read or interpreted. + /// + public static IReadOnlyList Discover(string packageDirectory, string packageId, string packageVersion) + { + var skillsRoot = FindSkillsFolder(packageDirectory); + + if (skillsRoot is null) + { + return []; + } + + var candidates = Directory.EnumerateDirectories(skillsRoot) + .Select(directory => new { Directory = directory, Name = Path.GetFileName(directory) }) + .Where(candidate => + IsSafeSkillName(candidate.Name) && + File.Exists(Path.Combine(candidate.Directory, SkillManifestFileName))) + .OrderBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.Name, StringComparer.Ordinal) + .ToList(); + + return + [ + .. candidates.Select(candidate => new BundledSkill( + packageId, + packageVersion, + candidate.Name, + candidate.Directory, + candidate.Name)), + ]; + } + + /// + /// Finds the skills folder case-insensitively, because package contents are authored on + /// case-insensitive file systems as often as not. + /// + private static string? FindSkillsFolder(string packageDirectory) + { + if (!Directory.Exists(packageDirectory)) + { + return null; + } + + return Directory.EnumerateDirectories(packageDirectory) + .FirstOrDefault(directory => + string.Equals(Path.GetFileName(directory), SkillsFolderName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Rejects names that would write outside the destination or produce an unusable path. The + /// name comes from a third-party package, so it is untrusted input even though the file + /// system has already resolved it to a real directory. Reject trailing dots and spaces on + /// every platform: Windows normalizes them, and an all-dot name can resolve to the parent. + /// + internal static bool IsSafeSkillName(string name) => + !string.IsNullOrWhiteSpace(name) && + !name.EndsWith('.') && + !name.EndsWith(' ') && + name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && + !name.Contains('/') && + !name.Contains('\\'); +} diff --git a/dotnet-package-skills/src/Skills/SkillInstaller.cs b/dotnet-package-skills/src/Skills/SkillInstaller.cs new file mode 100644 index 0000000..1fbb374 --- /dev/null +++ b/dotnet-package-skills/src/Skills/SkillInstaller.cs @@ -0,0 +1,460 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Skills; + +/// Outcome of an install. +public sealed record InstallOutcome( + IReadOnlyList Installed, + IReadOnlyList Removed, + IReadOnlyList Skipped) +{ + /// Tracked skills whose package this run did not offer. They were left exactly as they were. + public IReadOnlyList Untouched { get; init; } = []; +} + +/// Copies discovered skills into the destination and keeps the manifest in step. +public sealed class SkillInstaller +{ + /// + /// Copies every skill into , refreshing the ones this tool + /// already installed. + /// + /// + /// The version this run installs from for each package it covers, by package id. A tracked + /// skill is removed only when its package is offered at a different version that no longer + /// ships it, which is what lets an upgrade drop a skill instead of keeping a stale copy. + /// Tracked skills of packages that are not offered are left alone and reported as + /// untouched: a package leaving the project is not a reason to delete its skills here, and + /// that cleanup belongs to uninstall --stale. Pass an empty map to only add skills. + /// Null offers the packages of at their versions. + /// + /// + /// Spells the uninstall command that an error suggests, given its arguments, so it can name + /// the destination the caller was given. + /// + public InstallOutcome Install( + string destinationRoot, + IReadOnlyList skills, + bool dryRun, + IReadOnlyDictionary? offered = null, + IReadOnlyCollection? expectedInstalled = null, + Func? uninstallCommand = null) + { + // Package ids compare without regard to case, whatever comparer the caller's map uses: + // the manifest spells them in lowercase, and packages keep NuGet's casing. + var versions = offered is null + ? skills + .GroupBy(skill => skill.PackageId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().PackageVersion, StringComparer.OrdinalIgnoreCase) + : new Dictionary(offered, StringComparer.OrdinalIgnoreCase); + + using var destinationLock = DestinationLock.Acquire(destinationRoot); + var manifest = InstallManifest.Load(destinationRoot); + var trackedSkills = manifest.EnumerateSkills().ToList(); + CheckOwnershipSnapshot(trackedSkills, expectedInstalled); + var (selected, duplicateSkips) = SelectUniqueDestinations(skills, trackedSkills); + var accepted = new List(); + var skipped = new List(duplicateSkips); + var protectedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var skill in selected) + { + var tracked = trackedSkills.FirstOrDefault(entry => + entry.Skill.Equals(skill.RelativePath, StringComparison.OrdinalIgnoreCase)); + var destination = ToAbsolute(destinationRoot, skill.RelativePath); + + if (File.Exists(destination)) + { + skipped.Add(ToSkipped(skill, "the destination path already exists as a file")); + if (tracked is not null) + { + protectedPaths.Add(tracked.Skill); + } + + continue; + } + + if (tracked is null && Directory.Exists(destination)) + { + skipped.Add(ToSkipped( + skill, + "the destination folder already exists and is not managed by this tool")); + continue; + } + + if (tracked is not null && !HasSameOwner(tracked, skill)) + { + skipped.Add(ToSkipped( + skill, + $"the destination folder is managed for {tracked.Package} {tracked.Version} " + + $"skill '{tracked.Skill}'; uninstall that skill before replacing its owner")); + protectedPaths.Add(tracked.Skill); + continue; + } + + accepted.Add(skill); + } + + // A package moving to a version without one of its skills normally loses that skill. + // When another package in this run ships a skill of the same name, removing it would + // hand the name to that package, which takes an explicit uninstall. Keeping it would + // record the old version's copy under the new version, where no later run removes it. + var stranded = trackedSkills + .Where(entry => protectedPaths.Contains(entry.Skill)) + .Where(entry => versions.TryGetValue(entry.Package, out var version) && !SameVersion(version, entry.Version)) + .Where(entry => !skills.Any(skill => HasSameOwner(entry, skill))) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + + if (stranded.Count > 0) + { + throw NameWouldChangeOwner( + stranded, + versions, + selected, + uninstallCommand ?? (arguments => $"dotnet-package-skills uninstall {arguments}")); + } + + var current = accepted.Select(skill => skill.RelativePath).ToHashSet(StringComparer.OrdinalIgnoreCase); + + // A skill involved in a conflict is never removed by the same run. + var removed = trackedSkills + .Where(entry => !current.Contains(entry.Skill) && !protectedPaths.Contains(entry.Skill)) + .Where(entry => versions.TryGetValue(entry.Package, out var version) && !SameVersion(version, entry.Version)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + var removedPaths = removed.Select(entry => ToAbsolute(destinationRoot, entry.Skill)).ToList(); + var untouched = trackedSkills + .Where(entry => !versions.ContainsKey(entry.Package)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + + foreach (var skill in accepted) + { + if (!Directory.Exists(skill.SourcePath) || + !File.Exists(Path.Combine(skill.SourcePath, SkillDiscovery.SkillManifestFileName))) + { + throw new PackageSkillsException( + $"The source for skill '{skill.SkillName}' is no longer available at '{skill.SourcePath}'. " + + "Restore its package and run the command again. No skills were changed."); + } + } + + // What stays tracked keeps its entry, moved to the offered version when its package has + // one: the manifest records a single version per package, the one this run installed. + var kept = trackedSkills + .Where(entry => !current.Contains(entry.Skill) && !removed.Contains(entry)) + .Select(entry => versions.TryGetValue(entry.Package, out var version) ? entry with { Version = version } : entry); + var installed = accepted.Select(skill => + new TrackedSkill(skill.PackageId, skill.PackageVersion, skill.SkillName)); + + // Build the new ownership record before touching any file, so a record that breaks a + // manifest rule stops the operation while the destination is still unchanged. + manifest.SetSkills(kept.Concat(installed)); + + var outcome = new InstallOutcome(accepted, removed, skipped) { Untouched = untouched }; + + if (dryRun) + { + return outcome; + } + + foreach (var path in removedPaths) + { + RemoveSkillDirectory(path); + } + + foreach (var skill in accepted) + { + CopyDirectory(skill.SourcePath, ToAbsolute(destinationRoot, skill.RelativePath)); + } + + if (manifest.IsEmpty) + { + // Nothing is tracked, so there is nothing for the manifest to be the source of truth + // about. Match uninstall rather than leaving an empty manifest, and a destination + // folder, that the user never asked for. The folder only goes if it is empty, so + // skills they wrote themselves keep it alive. + InstallManifest.Delete(destinationRoot); + TryRemoveEmptyDirectory(destinationRoot); + } + else + { + manifest.Save(destinationRoot); + } + + return outcome; + } + + internal static bool SameVersion(string left, string right) => + PackagePathResolver.NormalizeVersion(left).Equals(PackagePathResolver.NormalizeVersion(right), StringComparison.Ordinal); + + private static PackageSkillsException NameWouldChangeOwner( + IReadOnlyList stranded, + IReadOnlyDictionary versions, + IReadOnlyList selected, + Func uninstallCommand) + { + // The offered map keeps NuGet's casing, which reads better than the manifest's. + string OwnerId(TrackedSkill entry) => + versions.Keys.First(id => id.Equals(entry.Package, StringComparison.OrdinalIgnoreCase)); + + var reasons = stranded.Select(entry => + { + var other = selected.FirstOrDefault(skill => + skill.RelativePath.Equals(entry.Skill, StringComparison.OrdinalIgnoreCase)); + return $"{OwnerId(entry)} {versions[entry.Package]} no longer ships the installed skill '{entry.Skill}', " + + $"and {(other is null ? "another package" : $"{other.PackageId} {other.PackageVersion}")} " + + "ships a skill with that name"; + }); + var owners = stranded.Select(OwnerId).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var command = owners.Count == 1 + ? $"'{uninstallCommand($"--package {owners[0]}")}'" + : $"'{uninstallCommand("--package ")}' for each of {string.Join(", ", owners)}"; + + return new PackageSkillsException( + $"Cannot install skills because {string.Join("; ", reasons)}. The tool doesn't hand an installed " + + "skill to another package, and the manifest records one version per package, so it can't keep the " + + $"older copy either. Run {command} first, and then try again. No skills were changed."); + } + + /// + /// A tracked skill is stale when the target references no package at its installed version: + /// the package left the project, or the project now uses another version of it. + /// + internal static bool IsStale(TrackedSkill entry, IEnumerable referenced) => + !referenced.Any(package => + package.Id.Equals(entry.Package, StringComparison.OrdinalIgnoreCase) && + SameVersion(package.Version, entry.Version)); + + /// + /// Removes skills this tool installed, narrowed to one package, one exact version of it, + /// an explicit set of skill names, or the skills that are stale against a target. + /// + /// + /// Skill folder names to remove. Null removes everything the other filters match, which is + /// what an unattended uninstall does; a set is what the interactive picker returns. + /// + /// + /// A target's direct package references. When given, only skills whose installed version + /// the target does not reference are removed. + /// + public IReadOnlyList Uninstall( + string destinationRoot, + string? packageId, + string? packageVersion, + bool dryRun, + IReadOnlyCollection? only = null, + IReadOnlyCollection? expectedInstalled = null, + IReadOnlyCollection? staleAgainst = null) + { + using var destinationLock = DestinationLock.Acquire(destinationRoot); + var manifest = InstallManifest.Load(destinationRoot); + + var chosen = only is null + ? null + : new HashSet(only, StringComparer.OrdinalIgnoreCase); + + var trackedSkills = manifest.EnumerateSkills().ToList(); + CheckOwnershipSnapshot(trackedSkills, expectedInstalled); + var targeted = trackedSkills + .Where(entry => Matches(entry, packageId, packageVersion)) + .Where(entry => staleAgainst is null || IsStale(entry, staleAgainst)) + .Where(entry => chosen is null || chosen.Contains(entry.Skill)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + var targetedPaths = targeted.Select(entry => ToAbsolute(destinationRoot, entry.Skill)).ToList(); + + if (targeted.Count == 0 || dryRun) + { + return targeted; + } + + manifest.SetSkills(trackedSkills.Except(targeted)); + + foreach (var path in targetedPaths) + { + RemoveSkillDirectory(path); + } + + if (manifest.IsEmpty) + { + InstallManifest.Delete(destinationRoot); + TryRemoveEmptyDirectory(destinationRoot); + } + else + { + manifest.Save(destinationRoot); + } + + // Report everything targeted, including entries whose folder a user had already + // deleted by hand: they are gone either way, and the manifest no longer claims them. + return targeted; + } + + internal static bool Matches(TrackedSkill entry, string? packageId, string? packageVersion) + { + if (packageId is not null && !entry.Package.Equals(packageId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Compare normalized, so 1.2 and 1.2.0 identify the same installed folder. + return packageVersion is null || + PackagePathResolver.NormalizeVersion(entry.Version) + .Equals(PackagePathResolver.NormalizeVersion(packageVersion), StringComparison.OrdinalIgnoreCase); + } + + private static void CheckOwnershipSnapshot( + IReadOnlyCollection installed, + IReadOnlyCollection? expected) + { + if (expected is not null && !expected.ToHashSet().SetEquals(installed)) + { + throw new PackageSkillsException( + "Installed skill ownership changed while the picker was open. " + + "No skills were changed by this operation. Run the command again to review the current state."); + } + } + + private static string ToAbsolute(string destinationRoot, string relativePath) + { + if (!SkillDiscovery.IsSafeSkillName(relativePath)) + { + throw UnsafeSkillPath(destinationRoot, relativePath); + } + + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(destinationRoot)); + var absolute = Path.GetFullPath(Path.Combine(root, relativePath)); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + if (!string.Equals(Path.GetDirectoryName(absolute), root, comparison)) + { + throw UnsafeSkillPath(destinationRoot, relativePath); + } + + return absolute; + } + + private static PackageSkillsException UnsafeSkillPath(string destinationRoot, string relativePath) => + new( + $"The path '{relativePath}' is not a safe skill folder directly inside '{destinationRoot}'. " + + "Restore the package or repair the install manifest before retrying. No skills were changed."); + + private static void RemoveSkillDirectory(string absolute) + { + if (Directory.Exists(absolute)) + { + Directory.Delete(absolute, recursive: true); + } + } + + private static bool TryRemoveEmptyDirectory(string directory) + { + if (!Directory.Exists(directory) || Directory.EnumerateFileSystemEntries(directory).Any()) + { + return false; + } + + try + { + Directory.Delete(directory); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + /// + /// Replaces the destination with a fresh copy of the source. + /// + /// + /// This copies rather than moves, and that is deliberate: the global packages folder is + /// NuGet's content-addressable cache. It is validated during restore and shared by every + /// project on the machine, so moving files out of it can make restore treat the cached + /// package as corrupt and strips the skill from every other repository using it. + /// + private static void CopyDirectory(string source, string destination) + { + if (Directory.Exists(destination)) + { + // Delete first so files removed in a newer package version do not survive. + Directory.Delete(destination, recursive: true); + } + + Directory.CreateDirectory(destination); + + foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, directory))); + } + + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + var target = Path.Combine(destination, Path.GetRelativePath(source, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target, overwrite: true); + + // Files in the global packages folder are marked read-only by restore. Copying + // carries that attribute over, which would make the next install fail to overwrite. + ClearReadOnly(target); + } + } + + private static void ClearReadOnly(string path) + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly); + } + } + + private static (List Selected, List Skipped) SelectUniqueDestinations( + IReadOnlyList skills, + IReadOnlyList installed) + { + var selected = new List(); + var skipped = new List(); + foreach (var group in skills.GroupBy(skill => skill.RelativePath, StringComparer.OrdinalIgnoreCase)) + { + var candidates = group.ToList(); + var owner = installed.FirstOrDefault(entry => entry.Skill.Equals(group.Key, StringComparison.OrdinalIgnoreCase)); + var retainedIndex = owner is null ? 0 : candidates.FindIndex(skill => HasSameOwner(owner, skill)); + retainedIndex = Math.Max(0, retainedIndex); + var retained = candidates[retainedIndex]; + selected.Add(retained); + for (var index = 0; index < candidates.Count; index++) + { + if (index == retainedIndex) + { + continue; + } + + skipped.Add(ToSkipped( + candidates[index], + $"conflicts with {retained.PackageId} {retained.PackageVersion} skill " + + $"'{retained.SkillName}', " + + (owner is not null && HasSameOwner(owner, retained) + ? "which belongs to the current owner" + : "which was selected first"))); + } + } + + return (selected, skipped); + } + + internal static bool HasSameOwner(TrackedSkill entry, BundledSkill skill) => + entry.Package.Equals(skill.PackageId, StringComparison.OrdinalIgnoreCase) && + entry.Skill.Equals(skill.SkillName, StringComparison.OrdinalIgnoreCase); + + private static SkippedSkill ToSkipped(BundledSkill skill, string reason) => + new( + skill.RelativePath, + skill.PackageId, + skill.PackageVersion, + skill.SkillName, + reason); +} diff --git a/dotnet-package-skills/tests/CommandLineDiagnosticsTests.cs b/dotnet-package-skills/tests/CommandLineDiagnosticsTests.cs new file mode 100644 index 0000000..31e4af7 --- /dev/null +++ b/dotnet-package-skills/tests/CommandLineDiagnosticsTests.cs @@ -0,0 +1,179 @@ +using System.CommandLine; +using System.Text; +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +public class CommandLineDiagnosticsTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Diagnostic_write_overloads_share_one_buffer_until_invocation_finishes(bool standardError) + { + using var destination = new StringWriter(); + using var other = new StringWriter(); + var root = new RootCommand(); + root.SetAction(result => + { + TextWriter writer = standardError + ? result.InvocationConfiguration.Error + : result.InvocationConfiguration.Output; + writer.Write(" first\u001b"); + writer.Flush(); + writer.Write(']'); + writer.Write("52;c;".ToCharArray()); + writer.Write("SECRET".AsSpan()); + writer.WriteAsync("\a").GetAwaiter().GetResult(); + writer.Write('\ud83d'); + writer.FlushAsync().GetAwaiter().GetResult(); + Assert.Empty(destination.ToString()); + writer.Write('\udc69'); + writer.Write(new StringBuilder("🏽‍💻 e\u0301界")); + writer.Write('\r'); + writer.Flush(); + writer.Write('\n'); + writer.WriteLine(" next "); + return 23; + }); + + var exitCode = CommandLineDiagnostics.Invoke( + root.Parse([]), standardError ? other : destination, standardError ? destination : other); + + Assert.Equal(23, exitCode); + Assert.Equal( + $" first👩🏽‍💻 e\u0301界{Environment.NewLine} next {Environment.NewLine}", + destination.ToString()); + Assert.Empty(other.ToString()); + } + + [Theory] + [InlineData("before\u001b[31mRED\u001b[0mafter", "beforeREDafter")] + [InlineData("before\u001b]52;c;SECRET\u001b\\after", "beforeafter")] + [InlineData("before\u001b]52;c;\r\nSECRET\aafter", "beforeafter")] + [InlineData("before\u009d52;c;SECRET\u009cafter", "beforeafter")] + [InlineData("before\u001bPSECRET\u001b\\after", "beforeafter")] + [InlineData("before\u001b(0after", "beforeafter")] + [InlineData("before\u001b]8;;https://example.invalid\a链接\u001b]8;;\aafter", "before链接after")] + [InlineData("👩🏽‍💻 e\u0301 中文 🇨🇦", "👩🏽‍💻 e\u0301 中文 🇨🇦")] + [InlineData(" \ue000first\r\n next\ue000 \r\n", " \ue000first\n next\ue000 \n")] + public void Every_write_boundary_preserves_text_without_leaking_escape_payloads(string text, string expected) + { + for (var split = 0; split <= text.Length; split++) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var root = new RootCommand(); + root.SetAction(result => + { + var writer = result.InvocationConfiguration.Error; + writer.Write(text.AsSpan(0, split)); + writer.Flush(); + writer.Write(text.ToCharArray(), split, text.Length - split); + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([]), output, error)); + Assert.Equal(expected.Replace("\n", Environment.NewLine, StringComparison.Ordinal), error.ToString()); + Assert.Empty(output.ToString()); + } + } + + [Theory] + [InlineData("before\ue000\u001b", "before\ue000")] + [InlineData("before\ue000\u001b[", "before\ue000")] + [InlineData("before\ue000\u001b]unterminated", "before\ue000")] + [InlineData("before\ue000\u009dunterminated", "before\ue000")] + [InlineData("before\ue000\u001b]unterminated\r\n", "before\ue000\n")] + [InlineData("before\ue000\u001b(", "before\ue000")] + [InlineData(" before \u001b]unterminated\r\n", " before \n")] + [InlineData("\u001b]unterminated\r\n", "\n")] + [InlineData("before\r\n\u001b]unterminated\r\n", "before\n")] + [InlineData("before\u001b[\r\n", "before\n")] + public void Unfinished_controls_preserve_valid_text_and_the_final_line_break(string text, string expected) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var root = new RootCommand(); + root.SetAction(result => + { + foreach (var character in text) + { + result.InvocationConfiguration.Error.Write(character); + } + + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([]), output, error)); + Assert.Equal(expected.Replace("\n", Environment.NewLine, StringComparison.Ordinal), error.ToString()); + } + + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public void Diagnostic_capture_preserves_the_callers_line_endings_and_leaves_writers_open(string newLine) + { + using var output = new StringWriter() { NewLine = newLine }; + using var error = new StringWriter() { NewLine = newLine }; + var root = new RootCommand(); + root.SetAction(result => + { + result.InvocationConfiguration.Output.WriteLine(" output"); + result.InvocationConfiguration.Output.WriteLine(); + result.InvocationConfiguration.Error.WriteLine(" error"); + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([]), output, error)); + output.Write("still open"); + error.Write("still open"); + + Assert.Equal($" output{newLine}{newLine}still open", output.ToString()); + Assert.Equal($" error{newLine}still open", error.ToString()); + } + + [Fact] + public void Default_framework_exception_diagnostics_are_sanitized_too() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var root = new RootCommand(); + root.SetAction((Func)(_ => + throw new InvalidOperationException("first\u001b]52;c;SECRET\a\nsecond"))); + + var exitCode = CommandLineDiagnostics.Invoke(root.Parse([]), output, error); + + Assert.Equal(1, exitCode); + Assert.Contains($"first{Environment.NewLine}second", error.ToString()); + Assert.DoesNotContain('\u001b', error.ToString()); + Assert.DoesNotContain('\a', error.ToString()); + Assert.DoesNotContain("SECRET", error.ToString()); + } + + [Fact] + public void Diagnostic_capture_does_not_change_canonical_values_or_global_console_streams() + { + const string Value = "original\u001b[31mvalue\u001b[0m"; + using var output = new StringWriter(); + using var error = new StringWriter(); + string? received = null; + var consoleOutput = Console.Out; + var consoleError = Console.Error; + var argument = new Argument("value"); + var root = new RootCommand { argument }; + root.SetAction(result => + { + Assert.Same(consoleOutput, Console.Out); + Assert.Same(consoleError, Console.Error); + received = result.GetValue(argument); + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([Value]), output, error)); + + Assert.Equal(Value, received); + Assert.Empty(output.ToString()); + Assert.Empty(error.ToString()); + } +} diff --git a/dotnet-package-skills/tests/CommandLineTests.cs b/dotnet-package-skills/tests/CommandLineTests.cs new file mode 100644 index 0000000..768fbc9 --- /dev/null +++ b/dotnet-package-skills/tests/CommandLineTests.cs @@ -0,0 +1,363 @@ +using System.CommandLine; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class CommandLineTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + public void A_supplied_blank_uninstall_filter_is_rejected(string filter) + { + var error = Assert.Throws(() => CommandLineBuilder.ParseUninstallFilter(filter)); + + Assert.Contains("non-empty package ID", error.Message); + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", "--package", filter]).Errors); + } + + [Theory] + [InlineData("--package")] + [InlineData("-p")] + public void An_uninstall_package_option_without_a_value_is_rejected(string option) + { + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", option]).Errors); + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", option, "--dry-run"]).Errors); + } + + [Fact] + public void Only_an_absent_uninstall_filter_means_all_packages() + { + Assert.Equal((null, null), CommandLineBuilder.ParseUninstallFilter(null)); + Assert.Equal(("Mockly", null), CommandLineBuilder.ParseUninstallFilter(" Mockly ")); + } + + [Theory] + [InlineData("--package", "only once")] + [InlineData("-p", "only once")] + [InlineData("--package=", "only once")] + [InlineData("--package=Alpha", "expects a single argument")] + public void Repeated_uninstall_filters_are_rejected_even_when_the_last_value_is_missing( + string repeated, string message) + { + var result = CommandLineBuilder.Build().Parse( + ["uninstall", "--dry-run", "--package", "Alpha", repeated]); + + Assert.Contains(result.Errors, error => error.Message.Contains(message, StringComparison.Ordinal)); + } + + [Theory] + [InlineData("_Acme")] + [InlineData("Acme_")] + [InlineData("_")] + public void Uninstall_accepts_valid_underscore_boundary_package_ids(string id) + { + Assert.Equal((id, null), CommandLineBuilder.ParseUninstallFilter(id)); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--package", id, "--dry-run"]).Errors); + } + + [Theory] + [InlineData("1.2", "1.2.0")] + [InlineData("1.2.0.0", "1.2.0")] + [InlineData("1.2.0-RC.1", "1.2.0-rc.1")] + public void Uninstall_filter_matching_normalizes_versions_for_both_modes(string filterVersion, string installedVersion) + { + var (id, version) = CommandLineBuilder.ParseUninstallFilter($"mockly@{filterVersion}"); + + Assert.True(SkillInstaller.Matches(new TrackedSkill("Mockly", installedVersion, "usage"), id, version)); + Assert.False(SkillInstaller.Matches(new TrackedSkill("Other", installedVersion, "usage"), id, version)); + } + + [Fact] + public void Uninstall_accepts_the_interactive_flag() + { + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "-i"]).Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--interactive"]).Errors); + } + + [Fact] + public void Uninstall_stale_accepts_a_target_and_every_other_uninstall_option_but_a_package() + { + Assert.Empty(CommandLineBuilder.Build().Parse( + ["uninstall", "--stale", "--target", "App.sln", "--dry-run", "-i", "-d", ".claude/skills"]) + .Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--stale", "-t", "src"]).Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--stale"]).Errors); + } + + [Fact] + public void Uninstall_stale_cannot_be_combined_with_a_package_filter() + { + var result = CommandLineBuilder.Build().Parse(["uninstall", "--stale", "--package", "Mockly"]); + + Assert.Contains(result.Errors, error => error.Message.Contains("--stale and --package cannot be combined")); + } + + [Theory] + [InlineData("--target")] + [InlineData("-t")] + public void Uninstall_target_needs_stale(string option) + { + var result = CommandLineBuilder.Build().Parse(["uninstall", option, "App.sln"]); + + Assert.Contains(result.Errors, error => + error.Message.Contains("--target can be used with uninstall only together with --stale", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("install", null)] + [InlineData("list", null)] + [InlineData("uninstall", "--stale")] + public void No_command_offers_no_restore(string command, string? extra) + { + // Restoring is left to dotnet list package and to the customer, so there is nothing to + // turn off here. + Assert.DoesNotContain( + CommandLineBuilder.Build().Subcommands.Single(candidate => candidate.Name == command).Options, + option => option.Name == "--no-restore"); + string[] args = extra is null ? [command, "--no-restore"] : [command, extra, "--no-restore"]; + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(args, output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unrecognized command or argument '--no-restore'", error.ToString()); + } + + [Theory] + [InlineData("install")] + [InlineData("list")] + public void Only_uninstall_has_the_stale_option(string command) + { + Assert.NotEmpty(CommandLineBuilder.Build().Parse([command, "--stale"]).Errors); + } + + [Fact] + public void The_interactive_install_help_says_installed_skills_are_not_listed() + { + var install = CommandLineBuilder.Build().Subcommands.Single(command => command.Name == "install"); + + var interactive = install.Options.Single(option => option.Name == "--interactive"); + + Assert.Contains("aren't installed", interactive.Description); + Assert.DoesNotContain("remove", interactive.Description, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("install")] + [InlineData("list")] + [InlineData("uninstall")] + public void No_command_offers_json_output(string command) + { + // Reports are for people. The manifest is the only machine-readable output. + Assert.DoesNotContain( + CommandLineBuilder.Build().Subcommands.Single(candidate => candidate.Name == command).Options, + option => option.Name == "--json"); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke([command, "--json"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unrecognized command or argument '--json'", error.ToString()); + } + + [Theory] + [InlineData("install")] + [InlineData("list")] + public void An_unknown_option_after_package_values_is_reported_as_unrecognized(string command) + { + // --package takes several values, so the parser hands it a trailing unknown option, such + // as a --json left in an old script, as one more value. + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke( + [command, "--package", "Mockly@1.10.0", "--json"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unrecognized command or argument '--json'", error.ToString()); + Assert.DoesNotContain("missing a version", error.ToString()); + } + + [Fact] + public void Uninstall_says_it_removes_from_the_destination_rather_than_copying_into_it() + { + var uninstall = CommandLineBuilder.Build() + .Subcommands.Single(command => command.Name == "uninstall"); + + var destination = uninstall.Options.Single(option => option.Name == "--destination"); + + // The option is shared-looking but not shared: install's wording is about copying in, + // which reads as nonsense on a command that only deletes. + Assert.Contains("remove skills from", destination.Description, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("copy", destination.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Uninstall_still_accepts_a_destination() + { + // Skills installed anywhere but the default are unreachable without it. + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "-d", ".claude/skills"]).Errors); + } + + [Fact] + public void Install_still_says_it_copies_into_the_destination() + { + var install = CommandLineBuilder.Build() + .Subcommands.Single(command => command.Name == "install"); + + var destination = install.Options.Single(option => option.Name == "--destination"); + + Assert.Contains("copy skills into", destination.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void The_removed_sync_verb_is_rejected() + { + // Renamed to install, which pairs with uninstall. A clean break at 0.1.0 rather than + // an alias, so nothing has to carry the old name forward. + var result = CommandLineBuilder.Build().Parse(["sync"]); + + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Install_rejects_the_removed_include_transitive_option() + { + var result = CommandLineBuilder.Build().Parse(["install", "--include-transitive"]); + + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Install_accepts_the_short_interactive_alias() + { + Assert.Empty(CommandLineBuilder.Build().Parse(["install", "-i"]).Errors); + } + + [Fact] + public void Install_accepts_interactive_alongside_a_named_package() + { + // One package can ship a dozen skills, so choosing among them is exactly the case + // --package plus --interactive exists for. + Assert.Empty(CommandLineBuilder.Build().Parse(["install", "--package", "Mockly@1.10.0", "-i"]).Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["install", "-i", "--package", "Mockly@1.10.0"]).Errors); + } + + [Fact] + public void List_does_not_offer_interactive_selection() + { + // list writes nothing, so there is nothing to choose between. + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["list", "--interactive"]).Errors); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Invalid_uninstall_filters_are_rejected_without_printing_terminal_controls(bool interactive) + { + const string Filter = "Safe\u001b]52;c;SECRET\aPackage"; + string[] args = interactive + ? ["uninstall", "--interactive", "--package", Filter] + : ["uninstall", "--package", Filter]; + var parsed = CommandLineBuilder.Build().Parse(args); + Assert.Contains(parsed.Errors, error => error.Message.Contains(Filter, StringComparison.Ordinal)); + Assert.Equal(Filter, parsed.GetValue("--package")); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(args, output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("'SafePackage' is not a valid package id.", error.ToString()); + Assert.DoesNotContain('\u001b', error.ToString()); + Assert.DoesNotContain('\a', error.ToString()); + Assert.DoesNotContain("SECRET", error.ToString()); + Assert.Contains("Usage:", output.ToString()); + } + + [Fact] + public void Uninstall_validation_keeps_multiline_guidance_readable() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke( + ["uninstall", "--package", "Mockly@1.*\u001b]52;c;SECRET\a"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains( + "'1.*' is a floating version or a version range, and this tool needs an exact version.\n" + + "Write it out, for example --package Mockly@1.10.0.\n" + + "To let restore choose the version, point at a project or solution with --target instead.", + error.ToString().ReplaceLineEndings("\n")); + Assert.DoesNotContain('\u001b', error.ToString()); + Assert.DoesNotContain("SECRET", error.ToString()); + } + + [Theory] + [InlineData("--unknown\u001b]52;c;SECRET\a")] + [InlineData("--unknown\u009d52;c;SECRET\u009c")] + [InlineData("--dry-run=false\u001b]52;c;SECRET\u001b\\")] + public void Framework_argument_errors_do_not_emit_terminal_controls(string token) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(["uninstall", token], output, error); + + Assert.Equal(1, exitCode); + Assert.NotEmpty(error.ToString()); + Assert.DoesNotContain("SECRET", output.ToString() + error.ToString()); + Assert.DoesNotContain(output.ToString() + error.ToString(), + character => char.IsControl(character) && character is not ('\r' or '\n')); + } + + [Fact] + public void Framework_typo_suggestions_are_sanitized_on_standard_output_too() + { + const string Token = "uninstal\u001b"; + var parsed = CommandLineBuilder.Build().Parse([Token]); + Assert.Contains(Token, parsed.UnmatchedTokens); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke([Token], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Did you mean", output.ToString()); + Assert.Contains("uninstall", output.ToString()); + Assert.Contains("Unrecognized", error.ToString()); + Assert.DoesNotContain('\u001b', output.ToString() + error.ToString()); + } + + [Theory] + [InlineData("--help", null)] + [InlineData("--version", null)] + [InlineData("uninstall", "--help")] + [InlineData("uninstall", "--missing")] + [InlineData("uninstal", null)] + public void Ordinary_framework_output_and_exit_codes_are_unchanged(string first, string? second) + { + string[] args = second is null ? [first] : [first, second]; + using var expectedOutput = new StringWriter(); + using var expectedError = new StringWriter(); + var expectedExitCode = CommandLineBuilder.Build().Parse(args).Invoke(new InvocationConfiguration + { + Output = expectedOutput, + Error = expectedError, + }); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(args, output, error); + + Assert.Equal(expectedExitCode, exitCode); + Assert.Equal(expectedOutput.ToString(), output.ToString()); + Assert.Equal(expectedError.ToString(), error.ToString()); + } +} diff --git a/dotnet-package-skills/tests/DestinationLockTests.cs b/dotnet-package-skills/tests/DestinationLockTests.cs new file mode 100644 index 0000000..cfbdc9b --- /dev/null +++ b/dotnet-package-skills/tests/DestinationLockTests.cs @@ -0,0 +1,151 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class DestinationLockTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Install_and_uninstall_wait_for_the_destination_owner_to_finish(bool uninstall) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var package = temp.CreatePackageWithSkill("Alpha", "1.0.0", "shared"); + var skill = new BundledSkill("Alpha", "1.0.0", "shared", Path.Combine(package, "skills", "shared"), "shared"); + var installer = new SkillInstaller(); + installer.Install(destination, [skill], dryRun: false); + var before = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + using var attempting = new ManualResetEventSlim(); + using var finished = new ManualResetEventSlim(); + using var held = DestinationLock.Acquire(destination); + Exception? failure = null; + var operation = new Thread(() => + { + try + { + attempting.Set(); + if (uninstall) + { + installer.Uninstall(destination, null, null, dryRun: false); + } + else + { + // A version without the skill removes it, which empties the destination. + installer.Install( + destination, + [], + dryRun: false, + offered: new Dictionary { ["Alpha"] = "2.0.0" }); + } + } + catch (Exception error) + { + failure = error; + } + finally + { + finished.Set(); + } + }) { IsBackground = true }; + operation.Start(); + + try + { + Assert.True(attempting.Wait(TimeSpan.FromSeconds(5))); + Assert.False(finished.Wait(TimeSpan.FromMilliseconds(100))); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + } + finally + { + held.Dispose(); + Assert.True(operation.Join(TimeSpan.FromSeconds(5))); + } + + Assert.Null(failure); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void A_busy_destination_returns_an_actionable_error_without_creating_files() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + using var held = DestinationLock.Acquire(destination); + Exception? observed = null; + var operation = new Thread(() => + { + try + { + using var competing = DestinationLock.Acquire(destination, TimeSpan.Zero); + } + catch (Exception error) + { + observed = error; + } + }) { IsBackground = true }; + operation.Start(); + + Assert.True(operation.Join(TimeSpan.FromSeconds(5))); + var error = Assert.IsType(observed); + + Assert.Contains("Another operation", error.Message); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Equivalent_destination_spellings_share_the_same_lock() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + Assert.Equal(DestinationLock.NameFor(destination), + DestinationLock.NameFor(Path.Combine(destination, "..", "dest") + Path.DirectorySeparatorChar)); + if (OperatingSystem.IsWindows()) + { + Assert.Equal(DestinationLock.NameFor(destination), DestinationLock.NameFor(destination.ToUpperInvariant())); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Extended_Windows_paths_share_the_ordinary_destination_lock(bool exists) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var temp = new TempDirectory(); + var destination = temp.Combine("nested", "skills"); + if (exists) + { + Directory.CreateDirectory(destination); + } + + Assert.Equal(DestinationLock.NameFor(destination), DestinationLock.NameFor(@"\\?\" + destination)); + Assert.Equal(exists, Directory.Exists(destination)); + } + + [Fact] + public void Existing_long_Windows_destinations_can_be_locked_with_either_spelling() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var temp = new TempDirectory(); + var destination = temp.Path; + for (var index = 0; index < 5; index++) + { + destination = Path.Combine(destination, new string('a', 60)); + } + + Directory.CreateDirectory(destination); + Assert.True(destination.Length > 260); + Assert.Equal(DestinationLock.NameFor(destination), DestinationLock.NameFor(@"\\?\" + destination)); + using var held = DestinationLock.Acquire(destination); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj b/dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj new file mode 100644 index 0000000..255daf6 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0;net10.0 + enable + enable + false + false + true + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet-package-skills/tests/FakeTerminal.cs b/dotnet-package-skills/tests/FakeTerminal.cs new file mode 100644 index 0000000..a14f3ff --- /dev/null +++ b/dotnet-package-skills/tests/FakeTerminal.cs @@ -0,0 +1,426 @@ +using System.Text; +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +internal sealed record TerminalWrite( + int Left, + int Top, + string Text, + TerminalStyle Style, + int WindowWidth, + int WindowHeight, + int OutputCodePage, + byte[] Bytes); + +/// A cell-addressed screen, scripted keys/resizes, and the styles of individual writes. +internal sealed class FakeTerminal(int windowHeight = 18, int windowWidth = 100) : ITerminal +{ + private List> _screen = []; + private readonly List _frames = []; + private readonly List _writes = []; + private readonly List> _frameWrites = []; + private readonly Queue<(ConsoleKeyInfo? Key, Action? BeforeKey)> _keys = new(); + private int _cursorTop; + private int _cursorLeft; + private int _frameWriteStart; + + public bool IsRedirected { get; init; } + + public bool SupportsColor { get; init; } = true; + + public int WindowHeight { get; private set; } = windowHeight; + + public int WindowWidth { get; private set; } = windowWidth; + + public int CursorTop => _cursorTop; + + public bool IsCursorVisible { get; private set; } = true; + + public bool IsControlCTakenAsInput { get; private set; } + + public bool ControlCWasEverTakenAsInput { get; private set; } + + public TerminalStyle CurrentStyle { get; private set; } + + public ConsoleColor Foreground { get; set; } = ConsoleColor.Gray; + + public ConsoleColor Background { get; set; } = ConsoleColor.Black; + + public Encoding OutputEncoding { get; set; } = Encoding.ASCII; + + public int ViewportClears { get; private set; } + + public bool IsInteractiveScreen { get; private set; } + + public int ScreenEntries { get; private set; } + + public int ScreenExits { get; private set; } + + public string LastPickerScreen { get; private set; } = string.Empty; + + public int LastPickerCursorTop { get; private set; } + + public Action? BeforeOperation { get; set; } + + public bool CursorVisible + { + set + { + BeforeOperation?.Invoke(nameof(CursorVisible)); + IsCursorVisible = value; + } + } + + public bool TreatControlCAsInput + { + set + { + BeforeOperation?.Invoke(nameof(TreatControlCAsInput)); + IsControlCTakenAsInput = value; + ControlCWasEverTakenAsInput |= value; + } + } + + public IReadOnlyList Frames => _frames; + + public IReadOnlyList Writes => _writes; + + public IReadOnlyList> FrameWrites => _frameWrites; + + public List<(int Width, int Height)> FrameSizes { get; } = []; + + public List CursorTopsAwaitingKey { get; } = []; + + public List StyleEvents { get; } = []; + + public List InputTimeouts { get; } = []; + + public List KeysRead { get; } = []; + + public List EncodingChanges { get; } = []; + + public string Screen => string.Join(Environment.NewLine, + _screen.Take(WindowHeight).Select(row => string.Concat(row.Take(WindowWidth)))); + + public int FinalCursorTop => _cursorTop; + + public int CursorTopAwaitingKey { get; private set; } + + public TerminalState CaptureState() => new( + IsCursorVisible, IsControlCTakenAsInput, Foreground, Background, CurrentStyle, OutputEncoding); + + public void UseUtf8Output() + { + BeforeOperation?.Invoke(nameof(UseUtf8Output)); + OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + EncodingChanges.Add(OutputEncoding); + } + + public IDisposable EnterInteractiveScreen() + { + BeforeOperation?.Invoke(nameof(EnterInteractiveScreen)); + var previous = _screen; + var previousTop = _cursorTop; + var previousLeft = _cursorLeft; + var wasInteractive = IsInteractiveScreen; + // Switching buffers preserves the cursor; the picker must position its own first frame. + _screen = []; + IsInteractiveScreen = true; + ScreenEntries++; + return new ScreenScope(() => + { + LastPickerScreen = Screen; + LastPickerCursorTop = _cursorTop; + _screen = previous; + _cursorTop = Math.Clamp(previousTop, 0, WindowHeight - 1); + _cursorLeft = Math.Clamp(previousLeft, 0, WindowWidth - 1); + IsInteractiveScreen = wasInteractive; + ScreenExits++; + }); + } + + private sealed class ScreenScope(Action restore) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + restore(); + } + } + } + + public void RestoreState(TerminalState state) + { + CurrentStyle = state.Style; + Foreground = state.Foreground ?? ConsoleColor.Gray; + Background = state.Background ?? ConsoleColor.Black; + IsCursorVisible = state.CursorVisible; + IsControlCTakenAsInput = state.TreatControlCAsInput; + StyleEvents.Add(CurrentStyle); + OutputEncoding = state.OutputEncoding; + EncodingChanges.Add(OutputEncoding); + } + + public void SetStyle(TerminalStyle style) + { + BeforeOperation?.Invoke(nameof(SetStyle)); + CurrentStyle = SupportsColor ? style : TerminalStyle.Default; + if (SupportsColor) + { + Foreground = style switch + { + TerminalStyle.Focus => ConsoleColor.Blue, + TerminalStyle.Selected => ConsoleColor.Blue, + TerminalStyle.Muted => ConsoleColor.DarkGray, + _ => ConsoleColor.Gray, + }; + Background = ConsoleColor.Black; + } + + StyleEvents.Add(CurrentStyle); + } + + public void ResetStyle() => SetStyle(TerminalStyle.Default); + + public FakeTerminal Press(params ConsoleKey[] keys) + { + foreach (var key in keys) + { + _keys.Enqueue((new ConsoleKeyInfo('\0', key, false, false, false), null)); + } + + return this; + } + + public FakeTerminal Press(ConsoleKey key, int times) + { + for (var press = 0; press < times; press++) + { + Press(key); + } + + return this; + } + + public FakeTerminal PressWith(ConsoleModifiers modifiers, ConsoleKey key, int times = 1) + { + for (var press = 0; press < times; press++) + { + _keys.Enqueue((new ConsoleKeyInfo( + '\0', key, + shift: (modifiers & ConsoleModifiers.Shift) != 0, + alt: (modifiers & ConsoleModifiers.Alt) != 0, + control: (modifiers & ConsoleModifiers.Control) != 0), null)); + } + + return this; + } + + public FakeTerminal Resize(int windowHeight, int windowWidth) => + ResizeBeforeKey(ConsoleKey.NoName, windowHeight, windowWidth); + + public FakeTerminal ResizeBeforeKey(ConsoleKey key, int windowHeight, int windowWidth) + { + _keys.Enqueue((new ConsoleKeyInfo('\0', key, false, false, false), + () => ApplyResize(windowHeight, windowWidth))); + return this; + } + + public FakeTerminal ResizeWhileWaiting(int windowHeight, int windowWidth) + { + _keys.Enqueue((null, () => ApplyResize(windowHeight, windowWidth))); + return this; + } + + public void ResizeNow(int windowHeight, int windowWidth) => ApplyResize(windowHeight, windowWidth); + + public FakeTerminal WaitWithoutKey(int times = 1) + { + for (var wait = 0; wait < times; wait++) + { + _keys.Enqueue((null, null)); + } + + return this; + } + + public void SetCursorPosition(int left, int top) + { + BeforeOperation?.Invoke(nameof(SetCursorPosition)); + if (left < 0 || left >= WindowWidth || top < 0 || top >= WindowHeight) + { + throw new InvalidOperationException($"Cursor ({left}, {top}) is outside {WindowWidth}x{WindowHeight}."); + } + + _cursorLeft = left; + _cursorTop = top; + } + + public void Write(string text) + { + BeforeOperation?.Invoke(nameof(Write)); + var bytes = OutputEncoding.GetBytes(text); + var displayed = OutputEncoding.GetString(bytes); + _writes.Add(new TerminalWrite(_cursorLeft, _cursorTop, displayed, CurrentStyle, + WindowWidth, WindowHeight, OutputEncoding.CodePage, bytes)); + EnsureRow(); + var row = _screen[_cursorTop]; + foreach (var element in TerminalText.Elements(displayed)) + { + if (element.Any(char.IsControl)) + { + throw new InvalidOperationException("A terminal span contained an unsanitized control."); + } + + var cells = TerminalText.CellWidth(element); + if (_cursorLeft + cells > WindowWidth || _cursorTop >= WindowHeight) + { + throw new InvalidOperationException($"Write is outside {WindowWidth}x{WindowHeight}: '{text}'."); + } + + while (row.Count < _cursorLeft + cells) + { + row.Add(" "); + } + + if (cells == 0) + { + if (_cursorLeft > 0) + { + var previous = _cursorLeft - 1; + while (previous > 0 && row[previous] is null) + { + previous--; + } + + row[previous] += element; + } + + continue; + } + + for (var cell = _cursorLeft; cell < _cursorLeft + cells; cell++) + { + var start = cell; + while (start > 0 && row[start] is null) + { + start--; + } + + var oldWidth = TerminalText.CellWidth(row[start] ?? " "); + for (var old = start; old < Math.Min(row.Count, start + oldWidth); old++) + { + row[old] = " "; + } + } + + row[_cursorLeft] = element; + for (var cell = 1; cell < cells; cell++) + { + row[_cursorLeft + cell] = null; + } + + _cursorLeft += cells; + if (_cursorLeft == WindowWidth) + { + AdvanceRow(); + EnsureRow(); + row = _screen[_cursorTop]; + } + } + } + + public void WriteLine(string text = "") + { + Write(text); + AdvanceRow(); + } + + public void ClearViewport() + { + BeforeOperation?.Invoke(nameof(ClearViewport)); + ViewportClears++; + _screen.Clear(); + _cursorLeft = 0; + _cursorTop = 0; + } + + public bool TryReadKey(TimeSpan timeout, out ConsoleKeyInfo key) + { + InputTimeouts.Add(timeout); + BeforeOperation?.Invoke(nameof(TryReadKey)); + if (_keys.TryPeek(out var next) && next.Key is null) + { + CaptureFrame(); + _keys.Dequeue().BeforeKey?.Invoke(); + key = default; + return false; + } + + key = ReadKey(); + return true; + } + + public ConsoleKeyInfo ReadKey() + { + CaptureFrame(); + BeforeOperation?.Invoke(nameof(ReadKey)); + + if (_keys.Count == 0) + { + throw new InvalidOperationException( + "The picker asked for a key the test did not script. Add one, or end with Enter or Escape."); + } + + var next = _keys.Dequeue(); + next.BeforeKey?.Invoke(); + var key = next.Key ?? throw new InvalidOperationException("An idle wait requires TryReadKey, not ReadKey."); + KeysRead.Add(key); + return key; + } + + private void CaptureFrame() + { + _frames.Add(Screen); + _frameWrites.Add(_writes.Skip(_frameWriteStart).ToArray()); + _frameWriteStart = _writes.Count; + FrameSizes.Add((WindowWidth, WindowHeight)); + CursorTopAwaitingKey = _cursorTop; + CursorTopsAwaitingKey.Add(_cursorTop); + } + + private void ApplyResize(int height, int width) + { + WindowHeight = height; + WindowWidth = width; + _cursorTop = Math.Min(_cursorTop, WindowHeight - 1); + _cursorLeft = Math.Min(_cursorLeft, WindowWidth - 1); + } + + private void EnsureRow() + { + while (_screen.Count <= _cursorTop) + { + _screen.Add([]); + } + } + + private void AdvanceRow() + { + _cursorLeft = 0; + if (_cursorTop < WindowHeight - 1) + { + _cursorTop++; + } + else + { + _screen.RemoveAt(0); + _screen.Add([]); + } + } +} diff --git a/dotnet-package-skills/tests/GlobalPackagesLocatorTests.cs b/dotnet-package-skills/tests/GlobalPackagesLocatorTests.cs new file mode 100644 index 0000000..e75aaa8 --- /dev/null +++ b/dotnet-package-skills/tests/GlobalPackagesLocatorTests.cs @@ -0,0 +1,54 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class GlobalPackagesLocatorTests +{ + private static string SomeAbsolutePath => Path.Combine(Path.GetTempPath(), "nuget-packages"); + + [Fact] + public void ParseListOutput_reads_the_path_from_current_SDK_output() + { + var output = $"global-packages: {SomeAbsolutePath}"; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_reads_the_path_from_older_prefixed_output() + { + // Older SDKs prefix the line, which is why parsing keys off the label. + var output = $"info : global-packages: {SomeAbsolutePath}"; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_ignores_surrounding_lines() + { + var output = $""" + Welcome to .NET! + ---------------- + global-packages: {SomeAbsolutePath} + + """; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_tolerates_windows_line_endings() + { + var output = $"info : something\r\nglobal-packages: {SomeAbsolutePath}\r\n"; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_returns_null_when_the_label_is_absent() => + Assert.Null(GlobalPackagesLocator.ParseListOutput("http-cache: /somewhere\ntemp: /elsewhere")); + + [Fact] + public void ParseListOutput_returns_null_when_the_label_has_no_value() => + Assert.Null(GlobalPackagesLocator.ParseListOutput("global-packages: ")); +} diff --git a/dotnet-package-skills/tests/InstallManifestTests.cs b/dotnet-package-skills/tests/InstallManifestTests.cs new file mode 100644 index 0000000..c58608d --- /dev/null +++ b/dotnet-package-skills/tests/InstallManifestTests.cs @@ -0,0 +1,347 @@ +using System.Security.AccessControl; +using System.Text; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class InstallManifestTests +{ + private const string Contents = """ + { + "version": 1, + "packages": { + "alpha": { "version": "1.0.0", "skills": ["alpha"] }, + "beta": { "version": "1.0.0", "skills": ["beta"] } + } + } + """; + + [Fact] + public void Saving_writes_the_versioned_packages_format_with_lf_line_endings_only() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var manifest = new InstallManifest(); + manifest.SetSkills( + [ + new TrackedSkill("Mockly", "1.10", "mockly-usage"), + new TrackedSkill("Mockly", "1.10.0", "mockly-migration"), + new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage"), + ]); + + manifest.Save(destination); + + var bytes = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + Assert.Equal( + "{\n" + + " \"version\": 1,\n" + + " \"packages\": {\n" + + " \"contoso.widgets\": {\n" + + " \"version\": \"2.3.0\",\n" + + " \"skills\": [\n" + + " \"contoso.widgets-usage\"\n" + + " ]\n" + + " },\n" + + " \"mockly\": {\n" + + " \"version\": \"1.10.0\",\n" + + " \"skills\": [\n" + + " \"mockly-migration\",\n" + + " \"mockly-usage\"\n" + + " ]\n" + + " }\n" + + " }\n" + + "}\n", + Encoding.UTF8.GetString(bytes)); + Assert.DoesNotContain((byte)'\r', bytes); + Assert.NotEqual(0xEF, bytes[0]); + } + + [Fact] + public void The_same_skills_produce_the_same_bytes_whatever_their_order_or_casing() + { + using var temp = new TempDirectory(); + var first = new InstallManifest(); + first.SetSkills( + [ + new TrackedSkill("Mockly", "1.0.0-RC.1", "b"), + new TrackedSkill("Alpha", "2.0", "a"), + new TrackedSkill("mockly", "1.0.0-rc.1", "a"), + ]); + var second = new InstallManifest(); + second.SetSkills( + [ + new TrackedSkill("alpha", "2.0.0", "a"), + new TrackedSkill("MOCKLY", "1.0.0-rc.1", "a"), + new TrackedSkill("Mockly", "1.0.0-RC.1", "b"), + ]); + + first.Save(temp.Combine("first")); + second.Save(temp.Combine("second")); + + Assert.Equal( + File.ReadAllBytes(temp.Combine("first", InstallManifest.FileName)), + File.ReadAllBytes(temp.Combine("second", InstallManifest.FileName))); + } + + [Fact] + public void A_saved_manifest_reads_back_with_lowercase_ids_and_normalized_versions() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var manifest = new InstallManifest(); + manifest.SetSkills( + [ + new TrackedSkill("Mockly", "1.10", "mockly-usage"), + new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage"), + ]); + manifest.Save(destination); + + var loaded = InstallManifest.Load(destination); + + Assert.Equal( + [ + new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage"), + new TrackedSkill("mockly", "1.10.0", "mockly-usage"), + ], + loaded.EnumerateSkills()); + Assert.Equal(["contoso.widgets", "mockly"], loaded.Packages.Keys); + Assert.Equal("1.10.0", loaded.Packages["mockly"].Version); + } + + [Fact] + public void One_package_cannot_be_recorded_at_two_versions() + { + var manifest = new InstallManifest(); + + var error = Assert.Throws(() => manifest.SetSkills( + [ + new TrackedSkill("Mockly", "1.10.0", "mockly-usage"), + new TrackedSkill("mockly", "1.11.0", "mockly-testing"), + ])); + + Assert.Contains("mockly", error.Message); + Assert.Contains("1.10.0", error.Message); + Assert.Contains("1.11.0", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Empty(manifest.EnumerateSkills()); + } + + [Fact] + public void A_package_id_that_the_reader_would_refuse_is_never_written() + { + // Whatever the tool writes, it has to be able to read back. Otherwise one install would + // lock every later command out of the destination. + var manifest = new InstallManifest(); + + var error = Assert.Throws(() => manifest.SetSkills( + [new TrackedSkill("not valid!", "1.0.0", "a")])); + + Assert.Contains("'not valid!'", error.Message); + Assert.Contains("not a valid package id", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Empty(manifest.EnumerateSkills()); + } + + [Fact] + public void Property_names_and_package_ids_are_read_without_regard_to_case() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + temp.CreateFile( + "dest/.dotnet-package-skills.json", + """{"Version":1,"Packages":{"Mockly":{"Version":"1.10.0","Skills":["mockly-usage"]}}}"""); + + Assert.Equal( + new TrackedSkill("mockly", "1.10.0", "mockly-usage"), + Assert.Single(InstallManifest.Load(destination).EnumerateSkills())); + } + + [Fact] + public void Unknown_properties_are_ignored_and_an_empty_packages_map_tracks_nothing() + { + using var temp = new TempDirectory(); + var withExtras = temp.CreateDirectory("extras"); + var empty = temp.CreateDirectory("empty"); + temp.CreateFile( + "extras/.dotnet-package-skills.json", + """ + { + "version": 1, + "comment": "not part of the format", + "packages": { "mockly": { "version": "1.10.0", "skills": ["mockly-usage"], "extra": true } } + } + """); + temp.CreateFile("empty/.dotnet-package-skills.json", """{"version":1,"packages":{}}"""); + + Assert.Equal("mockly-usage", Assert.Single(InstallManifest.Load(withExtras).EnumerateSkills()).Skill); + Assert.Empty(InstallManifest.Load(empty).EnumerateSkills()); + } + + [Theory] + [InlineData(2)] + [InlineData(10)] + public void A_manifest_from_a_newer_tool_asks_for_an_update_and_is_preserved(int formatVersion) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var contents = + "{\"version\":" + formatVersion + + ",\"packages\":{\"mockly\":{\"version\":\"1.10.0\",\"skills\":[\"mockly-usage\"]}}}"; + var path = temp.CreateFile("dest/.dotnet-package-skills.json", contents); + + var error = Assert.Throws(() => InstallManifest.Load(destination)); + + Assert.Contains($"format version {formatVersion}", error.Message); + Assert.Contains("supports only version 1", error.Message); + Assert.Contains("Update dotnet-package-skills", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(contents, File.ReadAllText(path)); + } + + [Fact] + public void A_manifest_in_the_pre_release_format_says_how_to_start_over_and_is_preserved() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + const string PreRelease = """ + { + "note": "Written by the dotnet-package-skills tool.", + "installed": [ { "package": "Mockly", "version": "1.10.0", "skills": ["mockly-usage"] } ] + } + """; + var path = temp.CreateFile("dest/.dotnet-package-skills.json", PreRelease); + + var error = Assert.Throws(() => InstallManifest.Load(destination)); + + Assert.Contains("pre-release version of dotnet-package-skills", error.Message); + Assert.Contains("Move the skills folder aside", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(PreRelease, File.ReadAllText(path)); + } + + [Theory] + [InlineData("null")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("""{"packages":{}}""")] + [InlineData("""{"version":"1","packages":{}}""")] + [InlineData("""{"version":1.5,"packages":{}}""")] + [InlineData("""{"version":0,"packages":{}}""")] + [InlineData("""{"version":-1,"packages":{}}""")] + [InlineData("""{"version":1}""")] + [InlineData("""{"version":1,"packages":null}""")] + [InlineData("""{"version":1,"packages":[]}""")] + [InlineData("""{"version":1,"version":1,"packages":{}}""")] + [InlineData("""{"version":1,"packages":{},"Packages":{}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":["a"]},"Mockly":{"version":"1.0.0","skills":["b"]}}}""")] + [InlineData("""{"version":1,"packages":{"not valid!":{"version":"1.0.0","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"contoso..widgets":{"version":"1.0.0","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly\n":{"version":"1.0.0","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":null}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":" ","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":1,"skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0"}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":"a"}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":[null]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":["../outside"]}}}""")] + [InlineData("""{"version":1,"packages":{"alpha":{"version":"1.0.0","skills":["shared"]},"beta":{"version":"1.0.0","skills":["SHARED"]}}}""")] + [InlineData("""{"version":1,"packages":{"alpha":{"version":"1.0.0","skills":["shared","shared"]}}}""")] + public void A_manifest_with_an_unusable_shape_fails_before_anything_uses_it(string contents) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", contents); + + var error = Assert.Throws(() => InstallManifest.Load(destination)); + + Assert.Contains("Could not read the install manifest", error.Message); + Assert.Contains("preserved", error.Message); + Assert.Equal(contents, File.ReadAllText(path)); + } + + [Fact] + public void The_first_save_creates_an_ordinary_manifest_file() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var path = Path.Combine(destination, InstallManifest.FileName); + var manifest = new InstallManifest(); + manifest.SetSkills([new TrackedSkill("New", "2.0.0", "new")]); + + manifest.Save(destination); + + Assert.True(File.Exists(path)); + Assert.Null(new FileInfo(path).LinkTarget); + Assert.False(File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint)); + Assert.Equal("new", Assert.Single(InstallManifest.Load(destination).EnumerateSkills()).Skill); + Assert.Equal(path, Assert.Single(Directory.EnumerateFileSystemEntries(destination))); + } + + [Fact] + public void Saving_a_regular_manifest_updates_it_without_creating_temporary_files() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", Contents); + var manifest = InstallManifest.Load(destination); + manifest.SetSkills([new TrackedSkill("New", "2.0.0", "new")]); + + manifest.Save(destination); + + Assert.Equal("new", Assert.Single(InstallManifest.Load(destination).EnumerateSkills()).Skill); + Assert.Equal(path, Assert.Single(Directory.EnumerateFileSystemEntries(destination))); + Assert.Null(new FileInfo(path).LinkTarget); + } + + [Fact] + public void A_read_only_manifest_is_not_silently_overwritten() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", Contents); + var attributes = File.GetAttributes(path); + File.SetAttributes(path, attributes | FileAttributes.ReadOnly); + try + { + Assert.Throws(() => new InstallManifest().Save(destination)); + Assert.Equal(Contents, File.ReadAllText(path)); + Assert.Equal(path, Assert.Single(Directory.EnumerateFileSystemEntries(destination))); + } + finally + { + File.SetAttributes(path, attributes); + } + } + + [Fact] + public void Saving_a_manifest_preserves_its_access_permissions() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", Contents); + if (OperatingSystem.IsWindows()) + { + var file = new FileInfo(path); + var access = file.GetAccessControl(AccessControlSections.Access); + access.SetAccessRuleProtection(isProtected: true, preserveInheritance: true); + file.SetAccessControl(access); + var before = file.GetAccessControl(AccessControlSections.Access) + .GetSecurityDescriptorSddlForm(AccessControlSections.Access); + + InstallManifest.Load(destination).Save(destination); + + Assert.Equal(before, new FileInfo(path).GetAccessControl(AccessControlSections.Access) + .GetSecurityDescriptorSddlForm(AccessControlSections.Access)); + } + else + { + var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(path, mode); + + InstallManifest.Load(destination).Save(destination); + + Assert.Equal(mode, File.GetUnixFileMode(path)); + } + } +} diff --git a/dotnet-package-skills/tests/InteractiveSkillsTests.cs b/dotnet-package-skills/tests/InteractiveSkillsTests.cs new file mode 100644 index 0000000..4ebc683 --- /dev/null +++ b/dotnet-package-skills/tests/InteractiveSkillsTests.cs @@ -0,0 +1,186 @@ +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class InteractiveSkillsTests +{ + [Fact] + public void Install_offers_only_skills_that_are_not_installed_with_their_descriptions() + { + using var temp = new TempDirectory(); + var first = Skill(temp, "first", "The first package skill."); + var second = Skill(temp, "second", "The second package skill."); + var source = File.ReadAllBytes(Path.Combine(first.SourcePath, "SKILL.md")); + + var items = InteractiveSkills.ForInstall([first, second], Owners("SECOND")); + + var item = Assert.Single(items); + Assert.Equal("first", item.Name); + Assert.Equal("Example.Package", item.Package); + Assert.Equal("1.0.0", item.Version); + Assert.Equal("The first package skill.", item.Description); + Assert.Null(item.DescriptionWarning); + Assert.Equal(source, File.ReadAllBytes(Path.Combine(first.SourcePath, "SKILL.md"))); + } + + [Fact] + public void Uninstall_reads_the_installed_copy_and_only_offers_manifest_owned_skills() + { + using var temp = new TempDirectory(); + var skill = Skill(temp, "example", "Description shipped with the installed version."); + var destination = temp.Combine("destination"); + new SkillInstaller().Install(destination, [skill], dryRun: false); + File.WriteAllText( + Path.Combine(skill.SourcePath, "SKILL.md"), + "---\ndescription: A different description now in the cache.\n---\n"); + var handwritten = Path.Combine(destination, "team-conventions"); + Directory.CreateDirectory(handwritten); + File.WriteAllText(Path.Combine(handwritten, "SKILL.md"), "---\ndescription: Our own skill.\n---\n"); + var manifest = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var tracked = SkillInstallService.InstalledSkills(destination, temp.Path); + var items = InteractiveSkills.ForUninstall(tracked, destination); + + var item = Assert.Single(items); + Assert.Equal("example", item.Name); + Assert.Equal("Description shipped with the installed version.", item.Description); + Assert.Equal(manifest, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + Assert.True(File.Exists(Path.Combine(handwritten, "SKILL.md"))); + } + + [Fact] + public void Missing_or_invalid_descriptions_do_not_remove_skills_from_the_picker() + { + using var temp = new TempDirectory(); + var missing = Skill(temp, "missing", "unused"); + var malformed = Skill(temp, "malformed", "unused"); + File.WriteAllText(Path.Combine(missing.SourcePath, "SKILL.md"), "# No frontmatter\n"); + File.WriteAllText(Path.Combine(malformed.SourcePath, "SKILL.md"), "---\ndescription: [broken\n---\n"); + + var items = InteractiveSkills.ForInstall([missing, malformed], []); + + Assert.Equal(2, items.Count); + var absent = Assert.Single(items, item => item.Name == "missing"); + Assert.Null(absent.Description); + Assert.Null(absent.DescriptionWarning); + var invalid = Assert.Single(items, item => item.Name == "malformed"); + Assert.Null(invalid.Description); + Assert.False(string.IsNullOrWhiteSpace(invalid.DescriptionWarning)); + } + + [Fact] + public void An_installed_skill_whose_file_is_missing_can_still_be_selected_for_removal() + { + using var temp = new TempDirectory(); + var skill = Skill(temp, "example", "An installed skill."); + var destination = temp.Combine("destination"); + var installer = new SkillInstaller(); + installer.Install(destination, [skill], dryRun: false); + File.Delete(Path.Combine(destination, "example", "SKILL.md")); + + var items = InteractiveSkills.ForUninstall( + SkillInstallService.InstalledSkills(destination, temp.Path), + destination); + + var item = Assert.Single(items); + Assert.Equal("example", item.Name); + Assert.Null(item.Description); + Assert.False(string.IsNullOrWhiteSpace(item.DescriptionWarning)); + var removed = installer.Uninstall(destination, null, null, dryRun: false, only: [item.Name]); + Assert.Equal("example", Assert.Single(removed).Skill); + } + + [Fact] + public void Description_loading_does_not_bypass_a_corrupt_ownership_manifest() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("destination"); + var manifest = Path.Combine(destination, InstallManifest.FileName); + const string conflict = "<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> branch"; + File.WriteAllText(manifest, conflict); + + Assert.Throws(() => + InteractiveSkills.ForUninstall( + SkillInstallService.InstalledSkills(destination, temp.Path), + destination)); + + Assert.Equal(conflict, File.ReadAllText(manifest)); + } + + [Fact] + public void An_install_choice_never_refreshes_or_removes_an_installed_skill() + { + using var temp = new TempDirectory(); + var installedSkill = Skill(temp, "installed", "Already installed."); + var fresh = Skill(temp, "fresh", "Not installed yet."); + var destination = temp.Combine("destination"); + var installer = new SkillInstaller(); + installer.Install(destination, [installedSkill], dryRun: false); + var edited = temp.CreateFile("destination/installed/SKILL.md", "edited locally"); + var installed = SkillInstallService.InstalledSkills(destination, temp.Path); + + var items = InteractiveSkills.ForInstall([installedSkill, fresh], installed); + var choice = InteractiveSkills.InstallChoice( + [installedSkill, fresh], installed, items, Names("installed", "fresh")); + var outcome = installer.Install( + destination, + choice.Selected, + dryRun: false, + offered: new Dictionary(), + expectedInstalled: choice.ExpectedInstalled); + + Assert.Equal("fresh", Assert.Single(items).Name); + Assert.Equal("fresh", Assert.Single(choice.Selected).SkillName); + Assert.Empty(outcome.Removed); + Assert.Equal("edited locally", File.ReadAllText(edited)); + Assert.Equal( + ["fresh", "installed"], + InstallManifest.Load(destination).EnumerateSkills().Select(entry => entry.Skill).Order(StringComparer.Ordinal)); + } + + [Fact] + public void An_install_choice_takes_only_ticked_skills_that_were_shown() + { + using var temp = new TempDirectory(); + var first = Skill(temp, "first", "One."); + var second = Skill(temp, "second", "Two."); + + var installed = Owners("first", "unshown"); + var items = InteractiveSkills.ForInstall([first, second], installed); + var choice = InteractiveSkills.InstallChoice( + [first, second], installed, items, Names("FIRST", "second", "unknown")); + + Assert.Equal([second], choice.Selected); + Assert.Same(installed, choice.ExpectedInstalled); + } + + [Fact] + public void Another_packages_same_named_skill_is_not_offered() + { + using var temp = new TempDirectory(); + var candidate = Skill(temp, "shared", "A candidate from Example.Package."); + var installed = new[] { new TrackedSkill("other.package", "2.0.0", "shared") }; + + var items = InteractiveSkills.ForInstall([candidate], installed); + var choice = InteractiveSkills.InstallChoice([candidate], installed, items, Names("shared")); + + Assert.Empty(items); + Assert.Empty(choice.Selected); + } + + private static IReadOnlyList Owners(params string[] names) => + [.. names.Select(name => new TrackedSkill("Example.Package", "1.0.0", name))]; + + private static HashSet Names(params string[] names) => new(names, StringComparer.OrdinalIgnoreCase); + + private static BundledSkill Skill(TempDirectory temp, string name, string description) + { + var package = temp.CreatePackageWithSkill("Example.Package", "1.0.0", name); + var directory = Path.Combine(package, "skills", name); + File.WriteAllText( + Path.Combine(directory, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n# Body\n"); + return new BundledSkill("Example.Package", "1.0.0", name, directory, name); + } +} diff --git a/dotnet-package-skills/tests/OutputLayoutTests.cs b/dotnet-package-skills/tests/OutputLayoutTests.cs new file mode 100644 index 0000000..7ff58a6 --- /dev/null +++ b/dotnet-package-skills/tests/OutputLayoutTests.cs @@ -0,0 +1,199 @@ +using DotnetPackageSkills; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +/// +/// Renders every report shape and checks its blank lines. +/// +/// +/// Vertical whitespace is invisible in a diff and obvious on a projector, so the rules that +/// keep it tidy are asserted rather than left to whoever edits the writer next. +/// +public class OutputLayoutTests +{ + private static readonly BundledSkill[] TwoSkills = + [ + new("Contoso.Widgets", "2.3.0", "contoso.widgets-usage", "/p/usage", "contoso.widgets-usage"), + new("Mockly", "1.10.0", "mockly-usage", "/p/mockly", "mockly-usage"), + ]; + + public static TheoryData EveryReport() + { + var data = new TheoryData(); + + foreach (var (name, report) in Reports()) + { + data.Add(name, report); + } + + return data; + } + + private static IEnumerable<(string Name, string Report)> Reports() + { + yield return ("list", Render(Result() with { DryRun = true }, copied: false)); + yield return ("install", Render(Result(), copied: true)); + yield return ("install dry run", Render(Result() with { DryRun = true }, copied: true)); + yield return ("install nothing found", Render( + Result() with { Skills = [], SkillsDiscovered = 0 }, copied: true)); + yield return ("install nothing chosen", Render( + Result() with { Skills = [], SkillsDiscovered = 2 }, copied: true)); + yield return ("install with removals", Render( + Result() with { Removed = [new TrackedSkill("Old.Package", "1.0.0", "old-skill")] }, + copied: true)); + yield return ("install with collisions", Render( + Result() with + { + Skipped = + [ + new SkippedSkill("shared", "Beta", "2.0.0", "shared", "conflicts with Alpha 1.0.0"), + ], + }, + copied: true)); + yield return ("install with unreferenced skills", Render( + Result() with { Unreferenced = [new TrackedSkill("left.package", "3.0.0", "left-skill")] }, + copied: true)); + yield return ("install with every section", Render( + Result() with + { + Removed = [new TrackedSkill("old.package", "1.0.0", "old-skill")], + Unreferenced = + [ + new TrackedSkill("left.package", "3.0.0", "left-skill"), + new TrackedSkill("gone.package", "1.0.0", "gone-skill"), + ], + Skipped = + [ + new SkippedSkill("shared", "Beta", "2.0.0", "shared", "conflicts with Alpha 1.0.0"), + ], + }, + copied: true)); + yield return ("interactive install with nothing new", Render( + Result() with { Skills = [], NothingNewToInstall = true }, copied: true)); + yield return ("interactive install with nothing new and collisions", Render( + Result() with + { + Skills = [], + NothingNewToInstall = true, + Skipped = + [ + new SkippedSkill("shared", "Beta", "2.0.0", "shared", "conflicts with Alpha 1.0.0"), + ], + }, + copied: true)); + yield return ("uninstall", RenderUninstall( + [new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage")], dryRun: false)); + yield return ("uninstall dry run", RenderUninstall( + [new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage")], dryRun: true)); + yield return ("uninstall nothing to do", RenderUninstall([], dryRun: false)); + yield return ("uninstall stale", RenderUninstall( + [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")], dryRun: false, + target: @"C:\repo\App.slnx")); + yield return ("uninstall stale nothing to do", RenderUninstall([], dryRun: false, target: @"C:\repo\App.slnx")); + yield return ("cancelled", RenderCancelled()); + } + + [Theory] + [MemberData(nameof(EveryReport))] + public void No_report_starts_or_ends_with_a_blank_line(string name, string report) + { + var lines = Lines(report); + + Assert.False(lines[0].Length == 0, $"{name} opens with a blank line"); + Assert.False(lines[^1].Length == 0, $"{name} closes with a blank line"); + } + + [Theory] + [MemberData(nameof(EveryReport))] + public void No_report_has_two_blank_lines_together(string name, string report) + { + var lines = Lines(report); + + for (var index = 1; index < lines.Count; index++) + { + Assert.False( + lines[index].Length == 0 && lines[index - 1].Length == 0, + $"{name} has a double blank line at {index + 1}"); + } + } + + [Theory] + [MemberData(nameof(EveryReport))] + public void No_report_wraps_a_sentence_onto_the_next_line(string name, string report) + { + // A line ending without terminal punctuation, followed by one starting lower case, + // is prose someone hard-wrapped at a width the reader never asked for. + var lines = Lines(report).Where(line => line.Length > 0 && !line.StartsWith(' ')).ToList(); + + for (var index = 0; index < lines.Count - 1; index++) + { + var ends = lines[index].TrimEnd(); + var next = lines[index + 1]; + + Assert.False( + ends.Length > 0 && ends[^1] is not ('.' or ':' or '!' or '?') && char.IsLower(next[0]), + $"{name}: '{ends}' looks wrapped into '{next}'"); + } + } + + [Fact] + public void A_skill_and_the_package_it_came_from_share_one_line() + { + var report = Render(Result(), copied: true); + + Assert.Contains("contoso.widgets-usage (Contoso.Widgets 2.3.0)", report); + // The old shape put "from Package Version" on its own indented line, doubling the + // length of every list to say something the brackets say for free. + Assert.DoesNotContain(" from ", report); + } + + [Fact] + public void A_twelve_skill_list_is_twelve_lines_of_skills() + { + var skills = Enumerable.Range(1, 12) + .Select(number => new BundledSkill( + "Contoso.Widgets", "2.3.0", $"skill-{number:00}", $"/p/{number}", $"skill-{number:00}")) + .ToList(); + + var report = Render(Result() with { Skills = skills }, copied: true); + + Assert.Equal(12, Lines(report).Count(line => line.StartsWith(" skill-", StringComparison.Ordinal))); + } + + private static List Lines(string report) => + [.. report.TrimEnd('\r', '\n').Split(Environment.NewLine)]; + + private static string Render(InstallResult result, bool copied) + { + using var output = new StringWriter(); + new OutputWriter(output).WriteInstallReport(result, copied); + return output.ToString(); + } + + private static string RenderUninstall(IReadOnlyList removed, bool dryRun, string? target = null) + { + using var output = new StringWriter(); + new OutputWriter(output).WriteUninstallReport(removed, @"C:\repo\.agents\skills", dryRun, target); + return output.ToString(); + } + + private static string RenderCancelled() + { + using var output = new StringWriter(); + new OutputWriter(output).WriteCancelled(); + return output.ToString(); + } + + private static InstallResult Result() => new() + { + Target = @"C:\repo\App.slnx", + GlobalPackagesFolder = @"C:\packages", + Destination = @"C:\repo\.agents\skills", + PackagesScanned = 3, + DryRun = false, + Skills = TwoSkills, + SkillsDiscovered = TwoSkills.Length, + }; +} diff --git a/dotnet-package-skills/tests/OutputWriterTests.cs b/dotnet-package-skills/tests/OutputWriterTests.cs new file mode 100644 index 0000000..95f969d --- /dev/null +++ b/dotnet-package-skills/tests/OutputWriterTests.cs @@ -0,0 +1,448 @@ +using System.Text.Json; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class OutputWriterTests +{ + private const string ClipboardControl = "\u001b]52;c;ZWNobyBleGFtcGxl\u0007"; + + [Fact] + public void The_trust_notice_is_a_single_line() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteInstallReport(ResultWithCollision(), copied: true); + + // Any break we pick is a guess at the reader's width. It is one thought, so it goes + // out as one line and the terminal wraps it wherever it needs to. + var notice = output.ToString() + .Split(Environment.NewLine) + .Single(line => line.StartsWith("These skills are", StringComparison.Ordinal)); + + Assert.EndsWith("Review them before relying on them.", notice); + } + + [Fact] + public void No_reported_line_breaks_in_the_middle_of_a_sentence() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteInstallReport(ResultWithCollision(), copied: true); + + // A line that ends without terminal punctuation, followed by one starting lower + // case, is prose someone hard-wrapped. Indented lines are data, not prose. + var lines = output.ToString() + .Split(Environment.NewLine) + .Where(line => line.Length > 0 && !line.StartsWith(' ')) + .ToList(); + + for (var index = 0; index < lines.Count - 1; index++) + { + var ends = lines[index].TrimEnd(); + var next = lines[index + 1]; + + Assert.False( + ends.Length > 0 && ends[^1] is not ('.' or ':' or '!' or '?') && char.IsLower(next[0]), + $"'{ends}' looks hard-wrapped into '{next}'"); + } + } + + [Fact] + public void Install_report_warns_about_skipped_collisions() + { + using var output = new StringWriter(); + var result = ResultWithCollision(); + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + var report = output.ToString(); + Assert.Contains("Warning: skipped 1 colliding skill:", report); + Assert.Contains("shared-skill (Beta.Widgets 2.0.0)", report); + Assert.Contains("selected first", report); + } + + [Fact] + public void Skills_whose_package_left_the_target_are_listed_with_the_command_that_removes_them() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Unreferenced = [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")], + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + Assert.Contains( + "1 installed skill belongs to a package that the target no longer references:" + Environment.NewLine + + " contoso.widgets-usage (contoso.widgets 2.3.0)" + Environment.NewLine + + "Run 'dotnet-package-skills uninstall --stale' to remove it.", + output.ToString()); + } + + [Theory] + [InlineData("other.package", "packages")] + [InlineData("contoso.widgets", "a package")] + public void The_stale_hint_counts_skills_and_packages_separately(string secondPackage, string packages) + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Unreferenced = + [ + new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage"), + new TrackedSkill(secondPackage, "2.3.0", "second-skill"), + ], + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + Assert.Contains($"2 installed skills belong to {packages} that the target no longer references:", output.ToString()); + Assert.Contains("Run 'dotnet-package-skills uninstall --stale' to remove them.", output.ToString()); + } + + [Fact] + public void The_stale_hint_prints_the_command_for_the_destination_and_target_that_were_used() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Unreferenced = [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")], + StaleCommand = "dotnet-package-skills uninstall --stale --destination \"my\u001b[2Jskills\"", + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + Assert.Contains( + "Run 'dotnet-package-skills uninstall --stale --destination \"myskills\"' to remove it.", + output.ToString()); + Assert.DoesNotContain('\u001b', output.ToString()); + } + + [Fact] + public void Deselecting_everything_does_not_claim_the_packages_ship_no_skills() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with { Skills = [], SkillsDiscovered = 2 }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + var report = output.ToString(); + Assert.Contains("Copied no skills.", report); + Assert.DoesNotContain("ship a skills/ folder", report); + } + + [Fact] + public void A_scan_that_discovered_nothing_says_so_plainly() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with { Skills = [], Skipped = [], SkillsDiscovered = 0 }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + // A package missing from the cache is indistinguishable from one without skills, so the + // report claims nothing about why nothing was found. + Assert.Contains($"{Environment.NewLine}No bundled skills found.{Environment.NewLine}", output.ToString()); + Assert.DoesNotContain("ship a skills/ folder", output.ToString()); + Assert.DoesNotContain("not extracted", output.ToString()); + } + + [Theory] + [InlineData(false, "Nothing new to install. Every skill that these packages ship is already installed.")] + [InlineData(true, "Nothing new to install.")] + public void An_interactive_install_with_nothing_to_offer_says_so(bool skipped, string expected) + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Skills = [], + SkillsDiscovered = 1, + NothingNewToInstall = true, + Skipped = skipped ? ResultWithCollision().Skipped : [], + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + var lines = output.ToString().Split(Environment.NewLine); + Assert.Contains(expected, lines); + Assert.DoesNotContain("Copied no skills.", lines); + Assert.Equal(skipped, output.ToString().Contains("Warning: skipped 1 colliding skill:", StringComparison.Ordinal)); + } + + [Fact] + public void List_reports_what_it_found_rather_than_a_pending_copy() + { + using var output = new StringWriter(); + + // list always runs as a dry run internally, but it is a query: it was never going + // to copy anything, so "Would copy" would misdescribe it. + new OutputWriter(output).WriteInstallReport(ResultWithCollision() with { DryRun = true }, copied: false); + + var report = output.ToString(); + Assert.Contains("Found 1 skill:", report); + Assert.DoesNotContain("Would copy", report); + } + + [Fact] + public void An_install_dry_run_still_says_what_it_would_copy() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteInstallReport(ResultWithCollision() with { DryRun = true }, copied: true); + + Assert.Contains("Would copy 1 skill:", output.ToString()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Install_and_list_reports_sanitize_every_untrusted_display_field(bool copied, bool dryRun) + { + var clean = ResultWithCollision() with + { + DryRun = dryRun, + Removed = [new TrackedSkill("Old.Package", "1.0.0", "old-skill")], + Unreferenced = [new TrackedSkill("left.package", "3.0.0", "left-skill")], + }; + var untrusted = clean with + { + Target = WithControls(clean.Target!), + GlobalPackagesFolder = WithControls(clean.GlobalPackagesFolder), + Destination = WithControls(clean.Destination), + Skills = + [ + .. clean.Skills.Select(skill => skill with + { + SkillName = WithControls(skill.SkillName), + RelativePath = WithControls(skill.RelativePath), + PackageId = WithControls(skill.PackageId), + PackageVersion = WithControls(skill.PackageVersion), + }), + ], + Removed = + [ + .. clean.Removed.Select(skill => new TrackedSkill( + WithControls(skill.Package), WithControls(skill.Version), WithControls(skill.Skill))), + ], + Unreferenced = + [ + .. clean.Unreferenced.Select(skill => new TrackedSkill( + WithControls(skill.Package), WithControls(skill.Version), WithControls(skill.Skill))), + ], + Skipped = + [ + .. clean.Skipped.Select(skill => skill with + { + SkillName = WithControls(skill.SkillName), + RelativePath = WithControls(skill.RelativePath), + PackageId = WithControls(skill.PackageId), + PackageVersion = WithControls(skill.PackageVersion), + Reason = WithControls(skill.Reason), + }), + ], + }; + using var expected = new StringWriter(); + using var actual = new StringWriter(); + + new OutputWriter(expected).WriteInstallReport(clean, copied); + new OutputWriter(actual).WriteInstallReport(untrusted, copied); + + AssertPlainText(actual.ToString()); + Assert.Equal(expected.ToString(), actual.ToString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Uninstall_reports_strip_clipboard_and_other_terminal_sequences(bool dryRun) + { + string[] controls = + [ + ClipboardControl, + "\u001b]52;c;ZWNobyBleGFtcGxl\u001b\\", + "\u009d52;c;ZWNobyBleGFtcGxl\u009c", + "\u001b[2J\u001b[H", + "\u009b2J", + "\u001b]8;;https://invalid.example\u001b\\", + "\u001bPignored\u001b\\", + "\u0007\u0008\u007f\u202e\u2066", + ]; + using var expected = new StringWriter(); + new OutputWriter(expected).WriteUninstallReport( + [new TrackedSkill("Example.Package", "1.0.0", "example-skill")], @"C:\repo", dryRun); + + foreach (var control in controls) + { + using var actual = new StringWriter(); + new OutputWriter(actual).WriteUninstallReport( + [new TrackedSkill("Example" + control + ".Package", "1.0" + control + ".0", + "example" + control + "-skill")], + @"C:\re" + control + "po", dryRun); + + AssertPlainText(actual.ToString()); + Assert.Equal(expected.ToString(), actual.ToString()); + } + } + + [Theory] + [InlineData(false, "Removed 1 skill:")] + [InlineData(true, "Would remove 1 skill:")] + public void A_stale_uninstall_report_names_the_target_it_compared_against(bool dryRun, string heading) + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage")], @"C:\repo\.agents\skills", dryRun, + target: @"C:\repo\App.sln"); + + Assert.Equal( + [ + @"Target: C:\repo\App.sln", + @"Destination: C:\repo\.agents\skills", + string.Empty, + heading, + " widget-usage (contoso.widgets 2.3.0)", + string.Empty, + ], + output.ToString().Split(Environment.NewLine)); + } + + [Fact] + public void A_stale_uninstall_with_nothing_stale_says_so() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport([], @"C:\repo\.agents\skills", dryRun: false, target: @"C:\repo\App.sln"); + + Assert.Contains("Nothing to remove. No stale skills were found.", output.ToString()); + Assert.DoesNotContain("No skills installed by this tool", output.ToString()); + } + + [Fact] + public void An_unterminated_control_in_one_identity_field_cannot_hide_the_following_fields() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("Example\u001b]52;c;unterminated", "1.0.0", "example-skill")], + @"C:\repo", dryRun: true); + + AssertPlainText(output.ToString()); + Assert.Contains("example-skill (Example 1.0.0)", output.ToString()); + } + + [Fact] + public void A_manifest_clipboard_payload_is_safe_to_preview_without_rewriting_identity_or_files() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var skillFile = temp.CreateFile("dest/example-skill/SKILL.md", "installed guidance"); + var handwritten = temp.CreateFile("dest/our-own-skill/SKILL.md", "handwritten guidance"); + // Package ids are validated when the manifest is read, so the version carries the payload. + var version = "1.0.0" + ClipboardControl; + var manifest = temp.CreateFile("dest/.dotnet-package-skills.json", JsonSerializer.Serialize(new + { + version = 1, + packages = new Dictionary + { + ["example"] = new { version, skills = new[] { "example-skill" } }, + }, + })); + var before = File.ReadAllBytes(manifest); + var removed = new SkillInstaller().Uninstall(destination, null, null, dryRun: true); + using var text = new StringWriter(); + + new OutputWriter(text).WriteUninstallReport(removed, destination, dryRun: true); + + AssertPlainText(text.ToString()); + Assert.Contains("example-skill (example 1.0.0)", text.ToString()); + Assert.Equal("example", Assert.Single(removed).Package); + Assert.Equal(version, Assert.Single(removed).Version); + Assert.Equal(before, File.ReadAllBytes(manifest)); + Assert.Equal("installed guidance", File.ReadAllText(skillFile)); + Assert.Equal("handwritten guidance", File.ReadAllText(handwritten)); + } + + [Theory] + [InlineData("\r\n")] + [InlineData("\n")] + [InlineData("\r")] + public void Operational_errors_are_sanitized_without_losing_line_breaks_or_stderr_routing(string newline) + { + using var output = new StringWriter(); + using var errors = new StringWriter(); + + new OutputWriter(output, errors).WriteError( + WithControls("Could not read the package.") + newline + WithControls("Restore it and try again.")); + + Assert.Empty(output.ToString()); + AssertPlainText(errors.ToString()); + Assert.Equal( + $"error: Could not read the package.{Environment.NewLine}Restore it and try again.{Environment.NewLine}", + errors.ToString()); + } + + [Fact] + public void Newlines_in_report_metadata_cannot_insert_additional_report_rows() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("Example\r\nPackage", "1.0.0", "example-skill")], @"C:\repo", dryRun: true); + + AssertPlainText(output.ToString()); + Assert.Contains(" example-skill (Example Package 1.0.0)", output.ToString()); + Assert.Equal(3, output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Length); + } + + [Fact] + public void Human_reports_preserve_ordinary_unicode_names() + { + using var output = new StringWriter(); + const string Skill = "\u6280\u80fd-\U0001f9ea"; + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("Caf\u00e9.Tools", "1.0.0", Skill)], @"C:\repo", dryRun: true); + + AssertPlainText(output.ToString()); + Assert.Contains($"{Skill} (Caf\u00e9.Tools 1.0.0)", output.ToString()); + } + + private static string WithControls(string text) => ClipboardControl + text + "\u001b[0m"; + + private static void AssertPlainText(string text) => + Assert.True( + !text.Any(character => char.IsControl(character) && character is not ('\r' or '\n') || + character is '\u202e' or '\u2066'), + "Captured human-readable output contains unsafe terminal controls."); + + private static InstallResult ResultWithCollision() => new() + { + Target = @"C:\repo\App.sln", + GlobalPackagesFolder = @"C:\packages", + Destination = @"C:\repo\.agents\skills", + PackagesScanned = 2, + DryRun = false, + Skills = + [ + new BundledSkill( + "Alpha.Widgets", + "1.0.0", + "shared-skill", + @"C:\packages\alpha.widgets\1.0.0\skills\shared-skill", + "shared-skill"), + ], + Skipped = + [ + new SkippedSkill( + "shared-skill", + "Beta.Widgets", + "2.0.0", + "shared-skill", + "conflicts with Alpha.Widgets 1.0.0 skill 'shared-skill', which was selected first"), + ], + }; +} diff --git a/dotnet-package-skills/tests/PackageCoordinateTests.cs b/dotnet-package-skills/tests/PackageCoordinateTests.cs new file mode 100644 index 0000000..0b1b890 --- /dev/null +++ b/dotnet-package-skills/tests/PackageCoordinateTests.cs @@ -0,0 +1,70 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class PackageCoordinateTests +{ + [Theory] + [InlineData("Mockly@1.10.0", "Mockly", "1.10.0")] + [InlineData("Contoso.Widgets@2.3.0", "Contoso.Widgets", "2.3.0")] + [InlineData("My_Package-Name@1.0.0-beta.1", "My_Package-Name", "1.0.0-beta.1")] + [InlineData("_Acme@1.0.0", "_Acme", "1.0.0")] + [InlineData("Acme_@1.0.0", "Acme_", "1.0.0")] + [InlineData("Widgets@2.0", "Widgets", "2.0")] + [InlineData("Widgets@1.2.3.4", "Widgets", "1.2.3.4")] + [InlineData("Widgets@1.2.3+sha.abc", "Widgets", "1.2.3+sha.abc")] + [InlineData(" Mockly@1.10.0 ", "Mockly", "1.10.0")] + [InlineData("Contoso.Überlib@1.0.0", "Contoso.Überlib", "1.0.0")] + public void Parse_accepts_an_exact_package_and_version(string input, string id, string version) + { + var coordinate = PackageCoordinate.Parse(input); + + Assert.Equal(id, coordinate.Id); + Assert.Equal(version, coordinate.Version); + } + + [Theory] + [InlineData("Mockly@4.*")] + [InlineData("Mockly@*")] + [InlineData("Mockly@1.2.*")] + [InlineData("Mockly@[1.0,2.0)")] + [InlineData("Mockly@(,3.0]")] + [InlineData("Mockly@[1.0]")] + public void Parse_refuses_floating_versions_and_ranges(string input) + { + var exception = Assert.Throws(() => PackageCoordinate.Parse(input)); + + // Guessing a version would copy skills describing a release the user does not use, + // so the message has to point at the option that resolves versions properly. + Assert.Contains("exact version", exception.Message); + Assert.Contains("--target", exception.Message); + } + + [Fact] + public void Parse_tells_the_user_how_to_add_a_missing_version() + { + var exception = Assert.Throws(() => PackageCoordinate.Parse("Mockly")); + + Assert.Contains("Mockly@1.10.0", exception.Message); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("@1.0.0")] + [InlineData("Mockly@")] + [InlineData("Mockly@@1.0.0")] + [InlineData("Mockly@not-a-version")] + [InlineData("Mockly@v1.0.0")] + [InlineData("../evil@1.0.0")] + [InlineData("path/to/thing@1.0.0")] + [InlineData("Contoso..Widgets@1.0.0")] + [InlineData("Contoso.-Widgets@1.0.0")] + [InlineData("Contoso\u200B.Widgets@1.0.0")] + public void Parse_rejects_malformed_input(string input) => + Assert.Throws(() => PackageCoordinate.Parse(input)); + + [Fact] + public void ToString_round_trips() => + Assert.Equal("Mockly@1.10.0", PackageCoordinate.Parse("Mockly@1.10.0").ToString()); +} diff --git a/dotnet-package-skills/tests/PackageListerTests.cs b/dotnet-package-skills/tests/PackageListerTests.cs new file mode 100644 index 0000000..d7e2502 --- /dev/null +++ b/dotnet-package-skills/tests/PackageListerTests.cs @@ -0,0 +1,212 @@ +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class PackageListerTests +{ + /// Records what was asked of the CLI and replays a canned result. + private sealed class RecordingRunner(int exitCode, string standardOutput, string standardError = "") + : IProcessRunner + { + public List Invocations { get; } = []; + + public ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null) + { + Invocations.Add(string.Join(' ', arguments)); + return new ProcessResult(exitCode, standardOutput, standardError); + } + } + + private const string UnrestoredError = + "No assets file was found for 'App.csproj'. Run restore before running this command."; + + /// What the .NET 10 SDK writes when its own restore fails during a JSON listing. + private const string RestoreFailedJson = """ + { + "version": 1, + "problems": [ + { "text": "Restore failed. Run `dotnet restore` for more details on the issue.", "level": "error" } + ] + } + """; + + [Fact] + public void List_runs_dotnet_list_package_without_a_restore_option() + { + // Whether listing restores is the SDK's call: .NET 10 restores when it needs to, and + // earlier SDKs say that the target has to be restored first. + var runner = new RecordingRunner(0, TwoProjectsJson); + + new PackageLister(new DotnetCli(runner)).List("App.csproj"); + + Assert.Equal("list App.csproj package --format json", Assert.Single(runner.Invocations)); + } + + [Fact] + public void An_unrestored_target_is_reported_with_the_sdk_output_and_the_tool_never_restores_it() + { + // The customer restores and runs the command again; the tool doesn't restore for them. + var runner = new RecordingRunner(1, string.Empty, UnrestoredError); + + var error = Assert.Throws( + () => new PackageLister(new DotnetCli(runner)).List("App.csproj")); + + Assert.Contains("'dotnet list \"App.csproj\" package' failed with exit code 1", error.Message); + Assert.Contains(UnrestoredError, error.Message); + Assert.Contains("and then run this command again", error.Message); + Assert.Equal("list App.csproj package --format json", Assert.Single(runner.Invocations)); + } + + [Fact] + public void Problems_that_the_sdk_reports_in_json_are_shown_as_text() + { + var runner = new RecordingRunner(1, RestoreFailedJson); + + var error = Assert.Throws( + () => new PackageLister(new DotnetCli(runner)).List("App.csproj")); + + Assert.Contains("error: Restore failed. Run `dotnet restore` for more details on the issue.", error.Message); + Assert.DoesNotContain("\"problems\"", error.Message); + Assert.Single(runner.Invocations); + } + + private const string TwoProjectsJson = """ + { + "version": 1, + "parameters": "", + "projects": [ + { + "path": "/repo/src/Api/Api.csproj", + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Serilog", "requestedVersion": "4.1.0", "resolvedVersion": "4.1.0" }, + { "id": "Mockly", "requestedVersion": "1.10.0", "resolvedVersion": "1.10.0" } + ], + "transitivePackages": [ + { "id": "System.Text.Json", "resolvedVersion": "8.0.5" } + ] + } + ] + }, + { + "path": "/repo/src/Worker/Worker.csproj", + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Serilog", "requestedVersion": "4.1.0", "resolvedVersion": "4.1.0" } + ] + } + ] + } + ] + } + """; + + [Fact] + public void Parse_returns_direct_packages_only() + { + var packages = PackageLister.Parse(TwoProjectsJson); + + Assert.Equal(["Mockly", "Serilog"], packages.Select(p => p.Id)); + } + + [Fact] + public void Parse_deduplicates_a_package_referenced_by_several_projects() + { + var packages = PackageLister.Parse(TwoProjectsJson); + + Assert.Single(packages, package => package.Id == "Serilog"); + } + + [Fact] + public void Parse_ignores_transitive_packages() + { + Assert.DoesNotContain(PackageLister.Parse(TwoProjectsJson), package => package.Id == "System.Text.Json"); + } + + [Fact] + public void Parse_keeps_both_versions_when_frameworks_resolve_a_package_differently() + { + // Each version has its own folder in the global packages cache, so both matter. + const string json = """ + { + "projects": [ + { + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ { "id": "Widgets", "resolvedVersion": "1.0.0" } ] }, + { "framework": "net10.0", "topLevelPackages": [ { "id": "Widgets", "resolvedVersion": "2.0.0" } ] } + ] + } + ] + } + """; + + var packages = PackageLister.Parse(json); + + Assert.Equal(["1.0.0", "2.0.0"], packages.Select(p => p.Version)); + } + + [Fact] + public void Parse_prefers_the_resolved_version_over_the_requested_one() + { + // Central Package Management and floating versions leave a range in + // requestedVersion; only resolvedVersion names a folder that exists. + const string json = """ + { + "projects": [ + { + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Widgets", "requestedVersion": "4.*", "resolvedVersion": "4.7.2" } + ] + } + ] + } + ] + } + """; + + Assert.Equal("4.7.2", PackageLister.Parse(json).Single().Version); + } + + [Fact] + public void Parse_handles_an_unrestored_project_with_no_frameworks_array() + { + const string json = """{ "version": 1, "projects": [ { "path": "/repo/src/Api/Api.csproj" } ] }"""; + + Assert.Empty(PackageLister.Parse(json)); + } + + [Fact] + public void Parse_handles_a_project_with_no_packages() + { + const string json = """ + { "projects": [ { "frameworks": [ { "framework": "net8.0", "topLevelPackages": [] } ] } ] } + """; + + Assert.Empty(PackageLister.Parse(json)); + } + + [Fact] + public void Parse_skips_MSBuild_noise_printed_before_the_payload() + { + var noisy = "warning NU1503: Skipping restore for project.\n" + TwoProjectsJson; + + Assert.NotEmpty(PackageLister.Parse(noisy)); + } + + [Fact] + public void Parse_reports_unusable_output_as_actionable_guidance() + { + var exception = Assert.Throws( + () => PackageLister.Parse("Unrecognized option '--format'")); + + Assert.Contains("7.0.200", exception.Message); + } +} diff --git a/dotnet-package-skills/tests/PackagePathResolverTests.cs b/dotnet-package-skills/tests/PackagePathResolverTests.cs new file mode 100644 index 0000000..698e5cf --- /dev/null +++ b/dotnet-package-skills/tests/PackagePathResolverTests.cs @@ -0,0 +1,80 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class PackagePathResolverTests +{ + [Theory] + // Already normalized. + [InlineData("1.2.3", "1.2.3")] + // Padded to three parts. + [InlineData("1.2", "1.2.0")] + [InlineData("1", "1.0.0")] + // A zero fourth part is dropped; a non-zero one is kept. + [InlineData("1.2.3.0", "1.2.3")] + [InlineData("1.2.3.4", "1.2.3.4")] + // Leading zeros are not part of the folder name. + [InlineData("01.02.03", "1.2.3")] + // Prerelease labels are preserved but lowercased. + [InlineData("1.0.0-Beta.1", "1.0.0-beta.1")] + [InlineData("2.0-RC1", "2.0.0-rc1")] + // Build metadata is not part of package identity. + [InlineData("1.2.3+build.99", "1.2.3")] + [InlineData("1.2.3-alpha+sha.abc", "1.2.3-alpha")] + [InlineData(" 1.2.3 ", "1.2.3")] + public void NormalizeVersion_matches_NuGet_folder_naming(string input, string expected) => + Assert.Equal(expected, PackagePathResolver.NormalizeVersion(input)); + + [Fact] + public void NormalizeVersion_leaves_unparseable_versions_alone_for_the_directory_scan() => + Assert.Equal("1.x.3", PackagePathResolver.NormalizeVersion("1.X.3")); + + [Fact] + public void Resolve_finds_the_lowercased_folder_for_a_mixed_case_package_id() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + + var resolved = PackagePathResolver.Resolve(temp.Combine("packages"), "Newtonsoft.Json", "13.0.3"); + + Assert.Equal(temp.Combine("packages", "newtonsoft.json", "13.0.3"), resolved); + } + + [Fact] + public void Resolve_normalizes_the_version_before_looking() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages", "serilog", "4.1.0"); + + Assert.NotNull(PackagePathResolver.Resolve(temp.Combine("packages"), "Serilog", "4.1")); + } + + [Fact] + public void Resolve_falls_back_to_scanning_when_normalization_does_not_match() + { + using var temp = new TempDirectory(); + + // A folder name our rules would not produce, so only the scan can find it. + temp.CreateDirectory("packages", "oddball", "1.2.3.4.5"); + + Assert.NotNull(PackagePathResolver.Resolve(temp.Combine("packages"), "Oddball", "1.2.3.4.5")); + } + + [Fact] + public void Resolve_returns_null_when_the_package_is_not_extracted() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages"); + + Assert.Null(PackagePathResolver.Resolve(temp.Combine("packages"), "Missing.Package", "1.0.0")); + } + + [Fact] + public void Resolve_returns_null_when_only_a_different_version_is_extracted() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages", "serilog", "4.1.0"); + + Assert.Null(PackagePathResolver.Resolve(temp.Combine("packages"), "Serilog", "3.0.0")); + } +} diff --git a/dotnet-package-skills/tests/PickerLayoutTests.cs b/dotnet-package-skills/tests/PickerLayoutTests.cs new file mode 100644 index 0000000..1f14b82 --- /dev/null +++ b/dotnet-package-skills/tests/PickerLayoutTests.cs @@ -0,0 +1,259 @@ +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +public class PickerLayoutTests +{ + [Fact] + public void Mixed_height_entries_have_exact_whole_entry_page_boundaries() + { + var layout = Layout(80, 24, [1, 3, 2, 4, 1, 2, 3, 1, 5, 2, 1, 4, 2, 3]); + + Assert.Equal( + [ + new PickerLayout.Page(0, 6, 13, false), + new PickerLayout.Page(6, 5, 12, false), + new PickerLayout.Page(11, 3, 9, false), + ], + layout.Pages); + Assert.Equal(0, layout.PageIndexFor(5)); + Assert.Equal(1, layout.PageIndexFor(6)); + Assert.Equal(2, layout.PageIndexFor(13)); + Assert.Equal(22, layout.MaxFrameHeight); + } + + [Fact] + public void Only_an_oversized_entry_gets_a_scrolling_one_item_page() + { + var layout = Layout(80, 24, [2, 50, 2, 1]); + + Assert.Equal( + [ + new PickerLayout.Page(0, 1, 2, false), + new PickerLayout.Page(1, 1, 13, true), + new PickerLayout.Page(2, 2, 3, false), + ], + layout.Pages); + Assert.Equal(37, layout.MaxScroll(1)); + Assert.Equal(0, layout.MaxScroll(0)); + Assert.Equal(0, layout.MaxScroll(2)); + Assert.Equal(23, layout.MaxFrameHeight); + } + + [Fact] + public void Forty_six_columns_measure_wrapped_rows_and_wrapped_controls() + { + var items = Enumerable.Range(1, 24) + .Select(number => new SkillPickerItem($"skill-{number:00}", $"Package.{number}", "1.0.0")) + .ToArray(); + + var layout = PickerLayout.For(items, "Skills for App.slnx", PickerMode.Install, 46, 18, true); + + Assert.Equal(45, layout.Width); + Assert.All(layout.Entries, entry => + { + Assert.Equal(17, entry.DescriptionColumn); + Assert.Equal(["No description provided."], entry.Description); + }); + Assert.Equal([5, 5, 5, 5, 4], layout.Pages.Select(page => page.Count)); + Assert.Equal(17, layout.MaxFrameHeight); + Assert.Contains("(Press to select, to accept)", layout.Help); + Assert.Contains(" to cancel)", string.Join(" ", layout.Help)); + } + + [Fact] + public void A_normal_eighty_by_twenty_four_window_uses_its_real_row_budget() + { + var layout = Layout(80, 24, Enumerable.Repeat(1, 24).ToArray()); + + Assert.Equal(new PickerLayout.Page(0, 14, 14, false), layout.Pages[0]); + Assert.Equal(new PickerLayout.Page(14, 10, 10, false), layout.Pages[1]); + Assert.Equal(23, layout.MaxFrameHeight); + } + + [Fact] + public void A_wide_tall_window_has_no_fixed_item_or_width_ceiling() + { + var layout = Layout(240, 100, Enumerable.Repeat(1, 60).ToArray()); + + Assert.Equal(new PickerLayout.Page(0, 60, 60, false), Assert.Single(layout.Pages)); + Assert.Equal(68, layout.MaxFrameHeight); + Assert.True(layout.Width < 80); + Assert.DoesNotContain(layout.Help, line => line.Contains("change page", StringComparison.Ordinal)); + } + + [Fact] + public void A_wrapped_title_takes_space_away_from_entries_not_from_the_footer() + { + var items = Enumerable.Range(1, 24) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "short")) + .ToArray(); + var title = string.Join(" ", Enumerable.Repeat("titleword", 20)); + + var layout = PickerLayout.For(items, title, PickerMode.Install, 80, 24, true); + + Assert.Equal(3, layout.Header(0).Count); + Assert.Equal(12, layout.Pages[0].Count); + Assert.Equal(2, layout.Pages.Count); + Assert.Equal($"{title} page 1 of 2", string.Join(" ", layout.Header(0))); + Assert.Equal(23, layout.MaxFrameHeight); + } + + [Fact] + public void Page_counter_digit_growth_is_included_in_the_fixed_point() + { + var items = Enumerable.Range(1, 120) + .Select(number => new SkillPickerItem($"skill-{number:000}", "Package", "1.0.0")) + .ToArray(); + + var layout = PickerLayout.For(items, new string('t', 32), PickerMode.Install, 46, 18, true); + + Assert.Equal(30, layout.Pages.Count); + Assert.Single(layout.Header(0)); + Assert.Equal(2, layout.Header(29).Count); + Assert.Equal(29, layout.PageIndexFor(119)); + Assert.All(layout.Pages, page => Assert.Equal(4, page.Count)); + Assert.True(layout.MaxFrameHeight < 18); + } + + [Fact] + public void Summary_reservation_measures_attainable_counts_not_impossible_combinations() + { + var items = Enumerable.Range(1, 10) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "Short.")) + .ToArray(); + + var layout = PickerLayout.For(items, "Skills", PickerMode.Install, 45, 18, true); + + Assert.Equal( + [ + new PickerLayout.Page(0, 5, 5, false), + new PickerLayout.Page(5, 5, 5, false), + ], + layout.Pages); + Assert.Equal(17, layout.MaxFrameHeight); + } + + [Fact] + public void Package_metadata_does_not_consume_space_or_change_page_boundaries() + { + var items = Enumerable.Range(1, 12) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "A short description.")) + .ToArray(); + var verboseMetadata = items.Select(item => item with + { + Package = new string('p', 120), + Version = "1.0.0-a-very-long-prerelease-version", + }).ToArray(); + + var original = PickerLayout.For(items, "Skills", PickerMode.Install, 100, 24, true); + var verbose = PickerLayout.For(verboseMetadata, "Skills", PickerMode.Install, 100, 24, true); + + Assert.Equal(original.Width, verbose.Width); + Assert.Equal(original.Pages, verbose.Pages); + Assert.Equal(original.Entries.Select(entry => entry.Label), verbose.Entries.Select(entry => entry.Label)); + Assert.Equal( + original.Entries.SelectMany(entry => entry.Description), + verbose.Entries.SelectMany(entry => entry.Description)); + } + + [Fact] + public void Shorter_names_give_their_descriptions_more_space_to_wrap() + { + const string description = "One two three four five six seven eight nine ten."; + var layout = PickerLayout.For( + [ + new SkillPickerItem("alpha", "P", "1", description), + new SkillPickerItem("longer-skill", "P", "1", description), + ], + "Skills", PickerMode.Install, 46, 24, true); + + Assert.Equal(14, layout.Entries[0].DescriptionColumn); + Assert.Equal( + ["One two three four five six", "seven eight nine ten."], + layout.Entries[0].Description); + Assert.Equal(21, layout.Entries[1].DescriptionColumn); + Assert.Equal( + ["One two three four five", "six seven eight nine ten."], + layout.Entries[1].Description); + Assert.Equal(6, layout.ContinuationColumn); + Assert.Equal(new PickerLayout.Page(0, 2, 4, false), Assert.Single(layout.Pages)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Every_keyboard_hint_starts_with_Press_in_both_modes(bool uninstall) + { + var items = Enumerable.Range(1, 24) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "Short.")) + .ToArray(); + var layout = PickerLayout.For( + items, "Skills", uninstall ? PickerMode.Uninstall : PickerMode.Install, 100, 18, true); + + Assert.Contains(PickerLayout.PrimaryHelp, layout.Help); + Assert.Contains("(Press / to move, / for first/last)", layout.Help); + Assert.Contains("(Press /, / to change page)", layout.Help); + Assert.Contains("(Press to select all, to clear all, // to cancel)", layout.Help); + Assert.All(layout.Help.Where(line => line.StartsWith('(')), line => Assert.StartsWith("(Press <", line)); + Assert.Equal( + "(Press / to scroll description: 1-4/20)", + PickerLayout.ScrollHelp(1, 4, 20)); + } + + [Fact] + public void Both_pickers_share_one_legend_and_rows_start_in_the_same_column_without_color() + { + var items = new[] { new SkillPickerItem("alpha", "P", "1", "One two three four five six seven eight nine ten.") }; + + var install = PickerLayout.For(items, "Skills", PickerMode.Install, 46, 24, true); + var uninstall = PickerLayout.For(items, "Skills", PickerMode.Uninstall, 46, 24, true); + var plain = PickerLayout.For(items, "Skills", PickerMode.Uninstall, 46, 24, false); + + Assert.Equal(install.Help, uninstall.Help); + Assert.Contains("Blue X: selected", uninstall.Help); + Assert.All(plain.Help, line => Assert.StartsWith("(Press <", line)); + Assert.Equal(install.ContinuationColumn, plain.ContinuationColumn); + Assert.Equal(install.Entries[0].DescriptionColumn, plain.Entries[0].DescriptionColumn); + Assert.Equal(install.Entries[0].Description, plain.Entries[0].Description); + } + + [Fact] + public void A_window_too_small_for_the_note_drops_the_note_rather_than_the_checklist() + { + // At 46x18 the scrolling description needs every row the rest of the frame leaves, so the + // note under the title gives way. A roomier window keeps it. + const string note = "Installed skills aren't listed."; + const string title = "Which skills should be installed? (App.slnx)"; + var items = new[] + { + new SkillPickerItem("skill-01", "P", "1", "short"), + new SkillPickerItem("skill-02", "P", "1", string.Join(" ", Enumerable.Repeat("word", 200))), + }; + + var small = PickerLayout.For(items, title, PickerMode.Install, 46, 18, true, note); + var roomy = PickerLayout.For(items, title, PickerMode.Install, 100, 30, true, note); + + Assert.DoesNotContain(note, small.Header(0)); + Assert.True(small.MaxFrameHeight < 18); + Assert.Contains(note, roomy.Header(0)); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(5, 20)] + [InlineData(80, 4)] + public void Impossible_viewports_are_rejected_with_resize_guidance(int width, int height) + { + var error = Assert.Throws(() => Layout(width, height, [1, 2])); + + Assert.Contains("Enlarge the window", error.Message); + Assert.Contains($"{width}x{height}", error.Message); + } + + private static PickerLayout Layout(int width, int height, int[] rows) => PickerLayout.For( + rows.Select((count, index) => new SkillPickerItem( + $"skill-{index + 1:00}", "P", "1", + string.Join("\n", Enumerable.Range(1, count).Select(line => $"line {line:00}")))).ToArray(), + "Skills", PickerMode.Install, width, height, true); +} diff --git a/dotnet-package-skills/tests/PipelineConfigurationTests.cs b/dotnet-package-skills/tests/PipelineConfigurationTests.cs new file mode 100644 index 0000000..4a2f67a --- /dev/null +++ b/dotnet-package-skills/tests/PipelineConfigurationTests.cs @@ -0,0 +1,294 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; +using SharpYaml; +using SharpYaml.Events; + +namespace DotnetPackageSkills.Tests; + +public class PipelineConfigurationTests +{ + [Fact] + public void Public_plan_excludes_production_resources_even_with_internal_source_variables() + { + var plan = ExpandPlan(official: false, TrustedVariables()); + + Assert.Equal("/eng/common/templates/jobs/jobs.yml", plan.Template); + Assert.Equal("True", Text(plan.Parameters["runAsPublic"])); + Assert.Equal("False", Text(plan.Parameters["enableMicrobuild"])); + Assert.Equal("False", Text(plan.Parameters["enablePublishBuildAssets"])); + Assert.Equal("False", Text(plan.Parameters["enablePublishing"])); + Assert.DoesNotContain("_SignType", plan.JobText); + Assert.DoesNotContain("_TeamName", plan.JobText); + Assert.DoesNotContain("SigningTeamName", plan.JobText); + Assert.DoesNotContain("templateContext", plan.Job.Keys.Cast()); + Assert.Contains("PublishPipelineArtifact@1", plan.JobText); + } + + [Fact] + public void Trusted_official_plan_uses_Arcade_MicroBuild_and_governed_success_only_packages() + { + var plan = ExpandPlan(official: true, TrustedVariables()); + + Assert.Equal("/eng/common/templates-official/jobs/jobs.yml@self", plan.Template); + Assert.Equal("False", Text(plan.Parameters["runAsPublic"])); + Assert.Equal("True", Text(plan.Parameters["enableMicrobuild"])); + Assert.Equal("False", Text(plan.Parameters["enableMicrobuildForMacAndLinux"])); + Assert.Contains("_SignType", plan.JobText); + Assert.Contains("real", plan.JobText); + Assert.Contains("Validate trusted source", plan.JobText); + Assert.DoesNotContain("PublishPipelineArtifact@1", plan.JobText); + var outputs = List(Map(plan.Job["templateContext"])["outputs"]).Select(Map).ToArray(); + Assert.Equal(3, outputs.Length); + var packages = Assert.Single(outputs, output => Text(output["artifactName"]) == "dotnet-package-skills-packages"); + Assert.Contains("succeeded()", Text(packages["condition"])); + Assert.Contains("DotnetPackageSkillsPackageVerified", Text(packages["condition"])); + Assert.All(outputs.Where(output => !ReferenceEquals(output, packages)), + output => Assert.Equal("False", Text(output["isProduction"]))); + } + + [Theory] + [InlineData("Build.Repository.Provider", "GitHub")] + [InlineData("Build.Repository.Name", "NuGet/Client.Tools")] + [InlineData("System.TeamProject", "public")] + [InlineData("System.CollectionUri", "https://dev.azure.com/dnceng-public/")] + [InlineData("Build.SourceBranch", "refs/heads/feature")] + [InlineData("Build.Reason", "PullRequest")] + [InlineData("Build.Reason", "")] + public void Untrusted_official_plans_reject_before_including_signing_resources(string name, string value) + { + var variables = TrustedVariables(); + variables[name] = value; + + var plan = ExpandPlan(official: true, variables); + + Assert.Equal("True", Text(plan.Parameters["runAsPublic"])); + Assert.Equal("False", Text(plan.Parameters["enableMicrobuild"])); + Assert.Equal("RejectUntrustedOfficialSource", Text(plan.Job["job"])); + Assert.DoesNotContain("_SignType", plan.JobText); + Assert.DoesNotContain("_TeamName", plan.JobText); + Assert.DoesNotContain("SigningTeamName", plan.JobText); + Assert.DoesNotContain("Invoke-Build.ps1", plan.JobText); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Expanded_steps_use_one_root_graph_and_never_pack_after_signing(bool official) + { + var plan = ExpandPlan(official, TrustedVariables()); + var steps = List(plan.Job["steps"]).Select(Map).ToArray(); + var scripts = steps.Where(step => step.ContainsKey("pwsh")).Select(step => Text(step["pwsh"])).ToArray(); + Assert.Equal(new[] { "RestoreBuild", "Test", "Pack", "Sign", "Verify" }, + scripts.Select(script => Regex.Match(script, @"-Action (\w+)").Groups[1].Value).Where(value => value != "")); + Assert.All(steps.Where(step => step.ContainsKey("workingDirectory")), + step => Assert.Equal("$(Build.SourcesDirectory)", Text(step["workingDirectory"]))); + var runtimes = steps.Where(step => step.TryGetValue("task", out var task) && Text(task) == "UseDotNet@2").ToArray(); + Assert.Equal(2, runtimes.Length); + Assert.All(runtimes, step => Assert.Equal("$(DOTNET_INSTALL_DIR)", Text(Map(step["inputs"])["installationPath"]))); + var publish = Assert.Single(steps, step => step.TryGetValue("task", out var task) && Text(task) == "PublishTestResults@2"); + Assert.Equal("True", Text(Map(publish["inputs"])["failTaskOnFailedTests"])); + Assert.Equal("True", Text(Map(publish["inputs"])["failTaskOnMissingResultsFile"])); + Assert.Equal("True", Text(Map(publish["inputs"])["failTaskOnFailureToPublishResults"])); + } + + [Fact] + public void Root_entry_points_preserve_main_only_triggers_and_1ES_governance() + { + var publicRoot = Read(Path.Combine(PipelineTestContext.RepoRoot, "eng", "pipelines", "pr.yml")); + Assert.Equal(new[] { "main" }, List(Map(Map(publicRoot["pr"])["branches"])["include"]).Select(Text)); + Assert.False(publicRoot.ContainsKey("resources")); + Assert.Single(List(publicRoot["stages"])); + + var officialRoot = Read(Path.Combine(PipelineTestContext.RepoRoot, "eng", "pipelines", "official.yml")); + Assert.Equal("$(Date:yyyyMMdd).$(Rev:r)", Text(officialRoot["name"])); + Assert.Equal(new[] { "main" }, List(officialRoot["trigger"]).Select(Text)); + Assert.Equal("v1/1ES.Official.PipelineTemplate.yml@1esPipelines", Text(Map(officialRoot["extends"])["template"])); + Assert.Single(List(Map(Map(officialRoot["extends"])["parameters"])["stages"])); + var release = Assert.Single(List(officialRoot["parameters"]).Select(Map)); + Assert.Equal("DotnetPackageSkillsReleaseBuild", Text(release["name"])); + Assert.Equal("False", Text(release["default"])); + } + + private static Dictionary TrustedVariables() => new() + { + ["Build.Repository.Provider"] = "TfsGit", + ["Build.Repository.Name"] = "NuGet-Client.Tools", + ["System.TeamProject"] = "internal", + ["System.CollectionUri"] = "https://dev.azure.com/dnceng/", + ["Build.SourceBranch"] = "refs/heads/main", + ["Build.Reason"] = "Manual", + }; + + private static ExpandedPlan ExpandPlan(bool official, Dictionary variables) + { + var root = ExpandOwned("stage.yml", new() { ["isOfficialBuild"] = official }, variables); + var stage = Map(Assert.Single(List(root["stages"]))); + Assert.Equal("DotnetPackageSkills", Text(stage["stage"])); + var template = Map(Assert.Single(List(stage["jobs"]))); + if (Text(template["template"]) == "jobs-build.yml") + { + var jobs = ExpandOwned("jobs-build.yml", Map(template["parameters"]), variables); + template = Map(Assert.Single(List(jobs["jobs"]))); + } + var parameters = Map(template["parameters"]); + var job = Map(Assert.Single(List(parameters["jobs"]))); + var steps = new List(); + foreach (var step in List(job["steps"]).Select(Map)) + { + if (step.TryGetValue("template", out var path) && Text(path) == "steps-build.yml") + { + steps.AddRange(List(ExpandOwned("steps-build.yml", Map(step["parameters"]), variables)["steps"])); + } + else { steps.Add(step); } + } + job["steps"] = steps; + return new(Text(template["template"]), parameters, job); + } + + // Expand owned templates only. Server-side public expansion and the authorized 1ES run are separate acceptance checks. + private static Dictionary ExpandOwned(string file, Dictionary overrides, + Dictionary variables) + { + var root = Read(PipelineTestContext.PipelinePath(file)); + var parameters = List(root["parameters"]).Select(Map).ToDictionary(parameter => Text(parameter["name"]), parameter => parameter["default"]); + foreach (var (key, value) in overrides) { parameters[Text(key)] = value; } + return Map(Expand(root, parameters, variables)); + } + + private static object Expand(object node, Dictionary parameters, Dictionary variables) + { + if (node is string text) + { + if (text.StartsWith("${{ ", StringComparison.Ordinal) && text.EndsWith(" }}", StringComparison.Ordinal) && + text.IndexOf("${{ ", 4, StringComparison.Ordinal) < 0) + { + return Evaluate(text[4..^3], parameters, variables); + } + return Regex.Replace(text, @"\$\{\{ (.*?) \}\}", match => Text(Evaluate(match.Groups[1].Value, parameters, variables))); + } + if (node is Dictionary mapping) + { + var expanded = new Dictionary(); + var matched = false; + foreach (var (key, value) in mapping) + { + if (Text(key).StartsWith("${{ if ", StringComparison.Ordinal)) + { + matched = Convert.ToBoolean(Evaluate(Text(key)[7..^3], parameters, variables), CultureInfo.InvariantCulture); + if (matched) + { + foreach (var (nestedKey, nestedValue) in Map(Expand(value, parameters, variables))) { expanded.Add(nestedKey, nestedValue); } + } + } + else if (Text(key) == "${{ else }}") + { + if (!matched) + { + foreach (var (nestedKey, nestedValue) in Map(Expand(value, parameters, variables))) { expanded.Add(nestedKey, nestedValue); } + } + } + else { expanded.Add(key, Expand(value, parameters, variables)); } + } + return expanded; + } + if (node is List sequence) + { + var expanded = new List(); + var matched = false; + foreach (var item in sequence) + { + if (item is Dictionary conditional && conditional.Count == 1 && + Text(conditional.Keys.Single()).StartsWith("${{ if ", StringComparison.Ordinal)) + { + matched = Convert.ToBoolean(Evaluate(Text(conditional.Keys.Single())[7..^3], parameters, variables), CultureInfo.InvariantCulture); + if (matched) { expanded.AddRange(List(Expand(conditional.Values.Single(), parameters, variables))); } + } + else if (item is Dictionary alternative && alternative.Count == 1 && alternative.ContainsKey("${{ else }}")) + { + if (!matched) { expanded.AddRange(List(Expand(alternative["${{ else }}"], parameters, variables))); } + } + else { expanded.Add(Expand(item, parameters, variables)); } + } + return expanded; + } + return node; + } + + private static object Evaluate(string expression, Dictionary parameters, Dictionary variables) + { + expression = expression.Trim(); + if (bool.TryParse(expression, out var boolean)) { return boolean; } + if (expression.StartsWith('\'') && expression.EndsWith('\'')) { return expression[1..^1]; } + if (expression.StartsWith("parameters.", StringComparison.Ordinal)) { return parameters[expression[11..]]; } + if (expression.StartsWith("variables['", StringComparison.Ordinal)) { return variables.GetValueOrDefault(expression[11..^2], ""); } + var opening = expression.IndexOf('('); + if (opening < 0 || !expression.EndsWith(')')) { throw new InvalidOperationException($"Unsupported template expression: {expression}"); } + var arguments = SplitArguments(expression[(opening + 1)..^1]).Select(argument => Evaluate(argument, parameters, variables)).ToArray(); + return expression[..opening] switch + { + "eq" => string.Equals(Text(arguments[0]), Text(arguments[1]), StringComparison.OrdinalIgnoreCase), + "ne" => !string.Equals(Text(arguments[0]), Text(arguments[1]), StringComparison.OrdinalIgnoreCase), + "and" => arguments.All(argument => Convert.ToBoolean(argument, CultureInfo.InvariantCulture)), + "not" => !Convert.ToBoolean(arguments[0], CultureInfo.InvariantCulture), + _ => throw new InvalidOperationException($"Unsupported template expression: {expression}"), + }; + } + + private static IEnumerable SplitArguments(string expression) + { + var start = 0; + var depth = 0; + var quoted = false; + for (var index = 0; index < expression.Length; index++) + { + var character = expression[index]; + if (character == '\'') { quoted = !quoted; } + if (quoted) { continue; } + if (character == '(') { depth++; } + if (character == ')') { depth--; } + if (character == ',' && depth == 0) { yield return expression[start..index]; start = index + 1; } + } + yield return expression[start..]; + } + + private static Dictionary Read(string path) + { + var reader = new EventReader(Parser.CreateParser(new StringReader(File.ReadAllText(path)))); + reader.Expect(); + reader.Expect(); + var mapping = Map(ReadNode(reader)); + reader.Expect(); + reader.Expect(); + return mapping; + } + + private static object ReadNode(EventReader reader) + { + if (reader.Allow() is { } scalar) + { + if (scalar.Style == ScalarStyle.Plain && bool.TryParse(scalar.Value, out var boolean)) { return boolean; } + return scalar.Value; + } + if (reader.Allow() is not null) + { + var mapping = new Dictionary(); + while (reader.Allow() is null) { mapping.Add(ReadNode(reader), ReadNode(reader)); } + return mapping; + } + if (reader.Allow() is not null) + { + var sequence = new List(); + while (reader.Allow() is null) { sequence.Add(ReadNode(reader)); } + return sequence; + } + throw new InvalidOperationException("Unsupported YAML node in pipeline configuration."); + } + private static Dictionary Map(object value) => Assert.IsType>(value); + private static List List(object value) => Assert.IsType>(value); + private static string Text(object value) => Convert.ToString(value, CultureInfo.InvariantCulture) ?? throw new InvalidOperationException("Missing YAML value."); + private sealed record ExpandedPlan(string Template, Dictionary Parameters, Dictionary Job) + { + internal string JobText => JsonSerializer.Serialize(Job); + } +} diff --git a/dotnet-package-skills/tests/PipelineInvocationTests.cs b/dotnet-package-skills/tests/PipelineInvocationTests.cs new file mode 100644 index 0000000..416dc00 --- /dev/null +++ b/dotnet-package-skills/tests/PipelineInvocationTests.cs @@ -0,0 +1,26 @@ +using System.Text.Json; + +namespace DotnetPackageSkills.Tests; + +public class PipelineInvocationTests +{ + [Fact] + public async Task Metadata_uses_Arcades_actual_sdk_without_requiring_an_optional_sdk_cache_file() + { + var result = await PipelineTestContext.Run("pwsh", + [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-File", + PipelineTestContext.PipelinePath("Invoke-Build.ps1"), "-Action", "Metadata", + ]); + + Assert.True(result.ExitCode == 0, result.Diagnostics); + using var document = JsonDocument.Parse(result.Output); + var metadata = document.RootElement; + Assert.Equal("0.1.0-dev", metadata.GetProperty("PackageVersion").GetString()); + Assert.Equal(metadata.GetProperty("Version").GetString(), metadata.GetProperty("PackageVersion").GetString()); + Assert.True(File.Exists(metadata.GetProperty("DotnetPath").GetString()), result.Output); + Assert.True(File.Exists(metadata.GetProperty("DotnetPackageSkillsStrongNameToolPath").GetString()), result.Output); + Assert.EndsWith("dotnet-package-skills.0.1.0-dev.nupkg", + metadata.GetProperty("DotnetPackageSkillsPackagePath").GetString()); + } +} diff --git a/dotnet-package-skills/tests/PipelinePackageTests.cs b/dotnet-package-skills/tests/PipelinePackageTests.cs new file mode 100644 index 0000000..9346aec --- /dev/null +++ b/dotnet-package-skills/tests/PipelinePackageTests.cs @@ -0,0 +1,305 @@ +using System.IO.Compression; +using System.Reflection; +using System.Security; +using System.Text; + +namespace DotnetPackageSkills.Tests; + +public class PipelinePackageTests +{ + [Fact] + public async Task Exact_package_installs_and_runs_on_both_runtimes() + { + using var fixture = new PipelinePackageFixture(); + + var result = await fixture.Verify(); + + Assert.True(result.ExitCode == 0, result.Diagnostics); + Assert.Contains("on .NET 8 and .NET 10", result.Output); + } + + [Theory] + [InlineData("tools/net8.0/any/Unmapped.dll")] + [InlineData("tools/net10.0/any/Unmapped.exe")] + [InlineData("tools/net8.0/win-x64/Unmapped.dll")] + [InlineData("tools/net9.0/any/Unmapped.dll")] + public async Task Undeclared_or_nonportable_payloads_fail_before_install(string entry) + { + using var fixture = new PipelinePackageFixture(); + fixture.SetEntry(entry, File.ReadAllBytes(fixture.OwnedAssembly("net8.0"))); + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("payload", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("tools/net8.0/any/SharpYaml.dll")] + [InlineData("tools/net8.0/any/System.Collections.Immutable.dll")] + [InlineData("tools/net10.0/any/System.CommandLine.dll")] + [InlineData("tools/net10.0/any/fr/System.CommandLine.resources.dll")] + public async Task Missing_declared_dependencies_fail_before_install(string entry) + { + using var fixture = new PipelinePackageFixture(); + fixture.RemoveEntry(entry); + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("missing", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + Assert.Contains(entry, result.Diagnostics); + } + + [Theory] + [InlineData("tools/net8.0/any/SharpYaml.dll")] + [InlineData("tools/net10.0/any/dotnet-package-skills.dll")] + public async Task Empty_signable_payloads_are_not_silently_skipped(string entry) + { + using var fixture = new PipelinePackageFixture(); + fixture.SetEntry(entry, []); + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("empty", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("tools/net8.0/any/SharpYaml.dll")] + [InlineData("tools/net10.0/any/fr/System.CommandLine.resources.dll")] + public async Task Redistributed_assembly_identity_cannot_change(string entry) + { + using var fixture = new PipelinePackageFixture(); + fixture.SetEntry(entry, File.ReadAllBytes(fixture.OwnedAssembly("net8.0"))); + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("identity", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("net8.0")] + [InlineData("net10.0")] + public async Task Owned_package_payload_must_match_the_tested_loose_assembly(string framework) + { + using var fixture = new PipelinePackageFixture(); + var bytes = File.ReadAllBytes(fixture.OwnedAssembly(framework)); + bytes[^1] ^= 1; + fixture.SetEntry($"tools/{framework}/any/dotnet-package-skills.dll", bytes); + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("does not match", result.Diagnostics); + } + + [Theory] + [InlineData("dotnet-package-skills", "renamed-tool", "ID")] + [InlineData("dotnet-package-skills contributors", "Changed", "authors")] + [InlineData("MIT", "Apache-2.0", "MIT")] + [InlineData("url=\"https://github.com/NuGet/Client.Tools\"", "url=\"https://example.invalid/tool\"", "origin")] + [InlineData("false", "true", "license acceptance")] + public async Task Package_identity_license_and_origin_metadata_are_preserved( + string original, string replacement, string diagnostic) + { + using var fixture = new PipelinePackageFixture(); + fixture.SetEntry("dotnet-package-skills.nuspec", Encoding.UTF8.GetBytes(fixture.Nuspec.Replace(original, replacement))); + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains(diagnostic, result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Version_drift_is_rejected() + { + using var fixture = new PipelinePackageFixture(); + + var result = await fixture.Verify(expectedVersion: "0.9.0"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("does not match", result.Diagnostics); + } + + [Fact] + public async Task Missing_readme_is_rejected() + { + using var fixture = new PipelinePackageFixture(); + fixture.RemoveEntry("README.md"); + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("README.md", result.Diagnostics); + } + + [Fact] + public async Task Duplicate_archive_entries_are_rejected() + { + using var fixture = new PipelinePackageFixture(); + using (var archive = ZipFile.Open(fixture.PackagePath, ZipArchiveMode.Update)) + { + using var writer = new StreamWriter(archive.CreateEntry("README.md").Open()); + writer.Write("Duplicate README."); + } + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("duplicate", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Archive_paths_cannot_escape_verification_scratch_space() + { + using var fixture = new PipelinePackageFixture(); + fixture.SetEntry("../escape.dll", File.ReadAllBytes(fixture.OwnedAssembly("net8.0"))); + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("path", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Unsigned_package_cannot_be_an_official_output() + { + using var fixture = new PipelinePackageFixture(); + + var result = await fixture.Verify(requireSigned: true); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("NuGet signature", result.Diagnostics); + } + + [Fact] + public async Task Missing_package_is_rejected() + { + using var fixture = new PipelinePackageFixture(); + File.Delete(fixture.PackagePath); + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + } + + [Fact] + public async Task A_corrupt_package_does_not_leave_extracted_payload_scratch_space() + { + using var fixture = new PipelinePackageFixture(); + File.WriteAllText(fixture.PackagePath, "Not a ZIP file."); + var before = Directory.GetDirectories(Path.GetTempPath(), "dotnet-package-skills-payload-*").ToHashSet(); + + var result = await fixture.Verify(); + + Assert.NotEqual(0, result.ExitCode); + var leaked = Directory.GetDirectories(Path.GetTempPath(), "dotnet-package-skills-payload-*") + .Where(directory => !before.Contains(directory)).ToArray(); + try { Assert.Empty(leaked); } + finally + { + foreach (var directory in leaked) { Directory.Delete(directory, recursive: true); } + } + } +} + +internal sealed class PipelinePackageFixture : IDisposable +{ + private readonly TempDirectory temporary = new(); + private static readonly Lazy> StrongNameTool = new(async () => + { + var properties = PipelineTestContext.Properties(await PipelineTestContext.Evaluate("net8.0")); + return Path.Combine(properties["NuGetPackageRoot"], "sn", properties["SNVersion"], "sn.exe"); + }); + + internal PipelinePackageFixture() + { + Version = typeof(PackageSkillsException).Assembly.GetCustomAttribute()! + .InformationalVersion.Split('+', 2)[0]; + BuildOutputPath = temporary.CreateDirectory("build"); + PackagePath = temporary.Combine($"dotnet-package-skills.{Version}.nupkg"); + foreach (var framework in new[] { "net8.0", "net10.0" }) + { + var source = Path.Combine(PipelineTestContext.RepoRoot, "artifacts", "bin", "DotnetPackageSkills", "Release", framework); + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, file); + if (relative.StartsWith("publish" + Path.DirectorySeparatorChar, StringComparison.Ordinal)) { continue; } + if (Path.GetExtension(file) is not (".dll" or ".json")) { continue; } + var copy = Path.Combine(BuildOutputPath, framework, relative); + Directory.CreateDirectory(Path.GetDirectoryName(copy)!); + File.Copy(file, copy); + } + } + using (var archive = ZipFile.Open(PackagePath, ZipArchiveMode.Create)) + { + foreach (var framework in new[] { "net8.0", "net10.0" }) + { + var directory = Path.Combine(BuildOutputPath, framework); + foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) + { + archive.CreateEntryFromFile(file, $"tools/{framework}/any/{Path.GetRelativePath(directory, file).Replace('\\', '/')}"); + } + WriteEntry(archive, $"tools/{framework}/any/DotnetToolSettings.xml", + ""); + } + WriteEntry(archive, "dotnet-package-skills.nuspec", Nuspec); + WriteEntry(archive, "README.md", "Package verification fixture."); + } + } + + internal string Version { get; } + internal string BuildOutputPath { get; } + internal string PackagePath { get; } + internal string Nuspec => + "dotnet-package-skills" + + $"{SecurityElement.Escape(Version)}" + + "dotnet-package-skills contributors" + + "Package verification fixture." + + "false" + + "MITREADME.md" + + "" + + ""; + + internal string OwnedAssembly(string framework) => Path.Combine(BuildOutputPath, framework, "dotnet-package-skills.dll"); + + internal async Task Verify(bool requireSigned = false, string? expectedVersion = null) + { + var arguments = new List + { + "-NoLogo", "-NoProfile", "-NonInteractive", "-File", PipelineTestContext.PipelinePath("Verify-Package.ps1"), + "-PackagePath", PackagePath, "-ExpectedVersion", expectedVersion ?? Version, + "-BuildOutputPath", BuildOutputPath, + "-DotnetPath", PipelineTestContext.DotnetPath, + "-StrongNameToolPath", await StrongNameTool.Value, + }; + if (requireSigned) { arguments.Add("-RequireSigned"); } + return await PipelineTestContext.Run("pwsh", arguments); + } + + internal void SetEntry(string name, byte[] bytes) + { + using var archive = ZipFile.Open(PackagePath, ZipArchiveMode.Update); + archive.GetEntry(name)?.Delete(); + using var stream = archive.CreateEntry(name).Open(); + stream.Write(bytes); + } + + internal void RemoveEntry(string name) + { + using var archive = ZipFile.Open(PackagePath, ZipArchiveMode.Update); + archive.GetEntry(name)!.Delete(); + } + + private static void WriteEntry(ZipArchive archive, string name, string content) + { + using var writer = new StreamWriter(archive.CreateEntry(name).Open(), new UTF8Encoding(false)); + writer.Write(content); + } + + public void Dispose() => temporary.Dispose(); +} diff --git a/dotnet-package-skills/tests/PipelineResultsTests.cs b/dotnet-package-skills/tests/PipelineResultsTests.cs new file mode 100644 index 0000000..959af85 --- /dev/null +++ b/dotnet-package-skills/tests/PipelineResultsTests.cs @@ -0,0 +1,115 @@ +namespace DotnetPackageSkills.Tests; + +public class PipelineResultsTests +{ + [Fact] + public async Task Both_frameworks_require_nonempty_successful_results() + { + using var temporary = new TempDirectory(); + WriteResults(temporary, "net8.0"); + WriteResults(temporary, "net10.0"); + + var result = await Check(temporary.Path); + + Assert.True(result.ExitCode == 0, result.Diagnostics); + Assert.Contains("net8.0", result.Output); + Assert.Contains("net10.0", result.Output); + } + + [Theory] + [InlineData("net8.0")] + [InlineData("net10.0")] + public async Task Missing_framework_results_fail_closed(string missing) + { + using var temporary = new TempDirectory(); + WriteResults(temporary, missing == "net8.0" ? "net10.0" : "net8.0"); + + var result = await Check(temporary.Path); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("missing", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + Assert.Contains(missing, result.Diagnostics); + } + + [Theory] + [InlineData("0", "0", "0", "0")] + [InlineData("2", "1", "1", "0")] + [InlineData("2", "2", "1", "1")] + [InlineData("garbage", "2", "2", "0")] + public async Task Empty_failed_incomplete_or_malformed_counters_fail_closed( + string total, string executed, string passed, string failed) + { + using var temporary = new TempDirectory(); + WriteResults(temporary, "net8.0", total, executed, passed, failed); + WriteResults(temporary, "net10.0"); + + var result = await Check(temporary.Path); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("results", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Passed_counters_cannot_hide_a_failed_individual_result() + { + using var temporary = new TempDirectory(); + var file = WriteResults(temporary, "net8.0"); + File.WriteAllText(file, File.ReadAllText(file).Replace("outcome=\"Passed\"", "outcome=\"Failed\"")); + WriteResults(temporary, "net10.0"); + + var result = await Check(temporary.Path); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("results", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task A_previous_run_cannot_supply_missing_current_results() + { + using var temporary = new TempDirectory(); + File.SetLastWriteTimeUtc(WriteResults(temporary, "net8.0"), DateTime.UtcNow.AddHours(-1)); + WriteResults(temporary, "net10.0"); + + var result = await Check(temporary.Path, DateTime.UtcNow.AddMinutes(-1)); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("stale", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Build_configuration_tests_cannot_replace_the_functional_suite() + { + using var temporary = new TempDirectory(); + WriteResults(temporary, "net8.0"); + WriteResults(temporary, "net10.0"); + var result = await PipelineTestContext.Run("pwsh", + [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-File", PipelineTestContext.PipelinePath("Test-Results.ps1"), + "-ResultsDirectory", temporary.Path, "-MinimumFunctionalTests", "792", + ]); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("functional", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + private static string WriteResults(TempDirectory directory, string framework, + string total = "2", string executed = "2", string passed = "2", string failed = "0") => + directory.CreateFile($"DotnetPackageSkills.Tests_{framework}_x64.trx", + "" + + $""); + + private static Task Check(string directory, DateTime? notBefore = null) + { + var arguments = new List + { + "-NoLogo", "-NoProfile", "-NonInteractive", "-File", PipelineTestContext.PipelinePath("Test-Results.ps1"), + "-ResultsDirectory", directory, + }; + if (notBefore is not null) + { + arguments.AddRange(["-NotBeforeUtc", notBefore.Value.ToString("O")]); + } + return PipelineTestContext.Run("pwsh", arguments); + } +} diff --git a/dotnet-package-skills/tests/PipelineRetirementTests.cs b/dotnet-package-skills/tests/PipelineRetirementTests.cs new file mode 100644 index 0000000..244f530 --- /dev/null +++ b/dotnet-package-skills/tests/PipelineRetirementTests.cs @@ -0,0 +1,18 @@ +namespace DotnetPackageSkills.Tests; + +public class PipelineRetirementTests +{ + [Fact] + public async Task Isolated_retirement_keeps_the_Arcade_foundation_and_another_tool() + { + var result = await PipelineTestContext.Run("pwsh", + [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-File", PipelineTestContext.PipelinePath("Test-Retirement.ps1"), + ]); + + Assert.True(result.ExitCode == 0, result.Diagnostics); + Assert.Contains("Arcade infrastructure validation succeeded", result.Output); + Assert.Contains("Future tool foundation validation succeeded", result.Output); + Assert.Contains("Isolated retirement validation succeeded", result.Output); + } +} diff --git a/dotnet-package-skills/tests/PipelineSigningTests.cs b/dotnet-package-skills/tests/PipelineSigningTests.cs new file mode 100644 index 0000000..e28aee6 --- /dev/null +++ b/dotnet-package-skills/tests/PipelineSigningTests.cs @@ -0,0 +1,42 @@ +namespace DotnetPackageSkills.Tests; + +public class PipelineSigningTests +{ + [Theory] + [InlineData("package", false)] + [InlineData("package", true)] + [InlineData("net8.0", false)] + [InlineData("net10.0", true)] + public async Task Explicit_signing_inputs_reject_missing_or_empty_package_and_loose_Dlls(string input, bool empty) + { + using var temporary = new TempDirectory(); + using var package = new PipelinePackageFixture(); + var metadata = PipelineTestContext.Properties(await PipelineTestContext.Evaluate("net8.0")); + var artifacts = temporary.CreateDirectory("artifacts"); + var inputs = new Dictionary + { + ["package"] = Path.Combine(temporary.CreateDirectory("artifacts", "packages", "Release", "Shipping"), + $"dotnet-package-skills.{metadata["PackageVersion"]}.nupkg"), + }; + File.Copy(package.PackagePath, inputs["package"]); + foreach (var framework in new[] { "net8.0", "net10.0" }) + { + inputs[framework] = Path.Combine(temporary.CreateDirectory("artifacts", "bin", "DotnetPackageSkills", "Release", framework), + "dotnet-package-skills.dll"); + File.Copy(package.OwnedAssembly(framework), inputs[framework]); + } + if (empty) { File.WriteAllBytes(inputs[input], []); } + else { File.Delete(inputs[input]); } + + var result = await PipelineTestContext.Run(Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", + [ + "/c", Path.Combine(PipelineTestContext.RepoRoot, "eng", "common", "build.cmd"), + "-sign", "-configuration", "Release", "/p:OfficialBuildId=", + $"/p:ArtifactsDir={artifacts}{Path.DirectorySeparatorChar}", "/p:NETCORE_ENGINEERING_TELEMETRY=false", + ]); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains(empty ? "empty signing input" : "missing signing input", result.Diagnostics); + Assert.DoesNotContain("SignToolTask starting", result.Diagnostics); + } +} diff --git a/dotnet-package-skills/tests/PipelineSourceTests.cs b/dotnet-package-skills/tests/PipelineSourceTests.cs new file mode 100644 index 0000000..5e85560 --- /dev/null +++ b/dotnet-package-skills/tests/PipelineSourceTests.cs @@ -0,0 +1,64 @@ +namespace DotnetPackageSkills.Tests; + +public class PipelineSourceTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Official_source_guard_accepts_only_an_explicit_trusted_team(bool release) + { + var result = await Check(release: release); + + Assert.True(result.ExitCode == 0, result.Diagnostics); + } + + [Theory] + [InlineData("BUILD_REPOSITORY_PROVIDER", "GitHub")] + [InlineData("BUILD_REPOSITORY_NAME", "NuGet/Client.Tools")] + [InlineData("SYSTEM_TEAMPROJECT", "public")] + [InlineData("SYSTEM_COLLECTIONURI", "https://dev.azure.com/dnceng-public/")] + [InlineData("BUILD_SOURCEBRANCH", "refs/heads/feature")] + [InlineData("BUILD_REASON", "PullRequest")] + [InlineData("BUILD_REASON", "")] + public async Task Official_source_guard_rejects_untrusted_or_missing_metadata(string name, string value) + { + var result = await Check(name, value); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("trusted", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("")] + [InlineData("$(DotnetPackageSkillsSigningTeamName)")] + [InlineData("unreviewed;property")] + public async Task Official_source_guard_requires_a_configured_signing_team(string value) + { + var result = await Check("DOTNET_PACKAGE_SKILLS_SIGNING_TEAM", value); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("team", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task An_automatic_official_run_cannot_request_a_stable_release() + { + var result = await Check("BUILD_REASON", "IndividualCI", release: true); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("manual", result.Diagnostics, StringComparison.OrdinalIgnoreCase); + } + + private static Task Check(string? name = null, string? value = null, bool release = false) + { + var environment = PipelineTestContext.OfficialInputs(); + environment["DOTNET_PACKAGE_SKILLS_SIGNING_TEAM"] = "FixtureApprovedTeam"; + if (name is not null) { environment[name] = value!; } + var arguments = new List + { + "-NoLogo", "-NoProfile", "-NonInteractive", "-File", PipelineTestContext.PipelinePath("Validate-OfficialSource.ps1"), + }; + if (release) { arguments.Add("-ReleaseBuild"); } + return PipelineTestContext.Run("pwsh", arguments, environment); + } +} diff --git a/dotnet-package-skills/tests/PipelineTestContext.cs b/dotnet-package-skills/tests/PipelineTestContext.cs new file mode 100644 index 0000000..1e5d374 --- /dev/null +++ b/dotnet-package-skills/tests/PipelineTestContext.cs @@ -0,0 +1,128 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace DotnetPackageSkills.Tests; + +internal static class PipelineTestContext +{ + internal static string RepoRoot { get; } = FindRepoRoot(); + + internal static string DotnetPath { get; } = + File.Exists(Path.Combine(RepoRoot, ".dotnet", "dotnet.exe")) + ? Path.Combine(RepoRoot, ".dotnet", "dotnet.exe") + : Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "..", "..", "..", "dotnet.exe")); + + internal static string PipelinePath(string name) => + Path.Combine(RepoRoot, "eng", "pipelines", "dotnet-package-skills", name); + + internal static async Task Evaluate( + string framework, IReadOnlyDictionary? inputs = null, + string project = @"dotnet-package-skills\src\DotnetPackageSkills.csproj") + { + var properties = new Dictionary + { + ["Configuration"] = "Release", + ["TargetFramework"] = framework, + ["ContinuousIntegrationBuild"] = "false", + ["OfficialBuildId"] = "", + ["BUILD_REASON"] = "", + ["BUILD_SOURCEBRANCH"] = "", + ["BUILD_REPOSITORY_PROVIDER"] = "", + ["BUILD_REPOSITORY_NAME"] = "", + ["SYSTEM_TEAMPROJECT"] = "", + ["SYSTEM_COLLECTIONURI"] = "", + }; + if (inputs is not null) + { + foreach (var (key, value) in inputs) { properties[key] = value; } + } + var arguments = new List + { + "msbuild", Path.Combine(RepoRoot, project), "-nologo", "-verbosity:quiet", + "-target:GetAssemblyVersion", + "-getProperty:Version,PackageVersion,VersionPrefix,VersionSuffixDateStamp,VersionSuffixBuildOfTheDay," + + "DotnetPackageSkillsReleaseBuild,DotNetFinalVersionKind,SignAssembly,IsPackable,IsShipping," + + "RuntimeIdentifier,RepoRoot,BaseOutputPath,BaseIntermediateOutputPath,PackageRequireLicenseAcceptance," + + "NuGetPackageRoot,SNVersion", + }; + arguments.AddRange(properties.Select(property => $"-property:{property.Key}={property.Value}")); + return await Run(DotnetPath, arguments); + } + + internal static Dictionary OfficialInputs(bool release = false) => new() + { + ["ContinuousIntegrationBuild"] = "true", + ["OfficialBuildId"] = "20261005.2", + ["DotnetPackageSkillsReleaseBuild"] = release ? "true" : "false", + ["BUILD_REASON"] = "Manual", + ["BUILD_SOURCEBRANCH"] = "refs/heads/main", + ["BUILD_REPOSITORY_PROVIDER"] = "TfsGit", + ["BUILD_REPOSITORY_NAME"] = "NuGet-Client.Tools", + ["SYSTEM_TEAMPROJECT"] = "internal", + ["SYSTEM_COLLECTIONURI"] = "https://dev.azure.com/dnceng/", + }; + + internal static Dictionary Properties(PipelineProcessResult result) + { + Assert.True(result.ExitCode == 0, result.Diagnostics); + using var document = JsonDocument.Parse(result.Output); + return document.RootElement.GetProperty("Properties").EnumerateObject() + .ToDictionary(property => property.Name, property => property.Value.GetString() ?? ""); + } + + internal static async Task Run(string command, IEnumerable arguments, + IReadOnlyDictionary? environment = null) + { + var start = new ProcessStartInfo(command) + { + WorkingDirectory = RepoRoot, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var argument in arguments) { start.ArgumentList.Add(argument); } + foreach (var name in new[] { "DotnetPackageSkillsReleaseBuild", "DotNetFinalVersionKind", "OfficialBuild" }) + { + start.Environment.Remove(name); + } + if (environment is not null) + { + foreach (var (name, value) in environment) { start.Environment[name] = value; } + } + + using var process = Process.Start(start) ?? throw new InvalidOperationException($"Could not start {command}."); + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + throw new TimeoutException($"{command} did not finish."); + } + return new PipelineProcessResult(process.ExitCode, await output, await error); + } + + private static string FindRepoRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "eng", "common", "build.cmd"))) + { + return directory.FullName; + } + } + throw new InvalidOperationException("Could not find the repository's Arcade entry point."); + } +} + +internal sealed record PipelineProcessResult(int ExitCode, string Output, string Error) +{ + internal string Diagnostics => Output + Environment.NewLine + Error; +} diff --git a/dotnet-package-skills/tests/PipelineVersionTests.cs b/dotnet-package-skills/tests/PipelineVersionTests.cs new file mode 100644 index 0000000..9801971 --- /dev/null +++ b/dotnet-package-skills/tests/PipelineVersionTests.cs @@ -0,0 +1,173 @@ +namespace DotnetPackageSkills.Tests; + +public class PipelineVersionTests +{ + [Theory] + [InlineData("net8.0", false, "0.1.0-dev")] + [InlineData("net10.0", false, "0.1.0-dev")] + [InlineData("net8.0", true, "0.1.0-ci")] + [InlineData("net10.0", true, "0.1.0-ci")] + public async Task Native_versions_match_local_and_public_builds(string framework, bool ci, string expected) + { + var result = await PipelineTestContext.Evaluate(framework, new Dictionary + { + ["ContinuousIntegrationBuild"] = ci ? "true" : "false", + }); + var properties = PipelineTestContext.Properties(result); + + Assert.Equal(expected, properties["Version"]); + Assert.Equal(expected, properties["PackageVersion"]); + Assert.Equal("false", properties["DotnetPackageSkillsReleaseBuild"]); + Assert.Empty(properties["DotNetFinalVersionKind"]); + } + + [Theory] + [InlineData("net8.0", false)] + [InlineData("net10.0", false)] + [InlineData("net8.0", true)] + [InlineData("net10.0", true)] + public async Task Native_official_versions_use_the_same_inputs_for_assembly_and_package(string framework, bool release) + { + var properties = PipelineTestContext.Properties( + await PipelineTestContext.Evaluate(framework, PipelineTestContext.OfficialInputs(release))); + var expected = release + ? "0.1.0" + : $"0.1.0-beta.{properties["VersionSuffixDateStamp"]}.{properties["VersionSuffixBuildOfTheDay"]}"; + + Assert.Equal(expected, properties["Version"]); + Assert.Equal(expected, properties["PackageVersion"]); + Assert.Equal("26505", properties["VersionSuffixDateStamp"]); + Assert.Equal("2", properties["VersionSuffixBuildOfTheDay"]); + Assert.Equal(release ? "release" : "", properties["DotNetFinalVersionKind"]); + } + + [Theory] + [InlineData("net8.0")] + [InlineData("net10.0")] + public async Task Native_official_revision_changes_the_prerelease(string framework) + { + var inputs = PipelineTestContext.OfficialInputs(); + inputs["OfficialBuildId"] = "20261005.3"; + var properties = PipelineTestContext.Properties(await PipelineTestContext.Evaluate(framework, inputs)); + + Assert.Equal("0.1.0-beta.26505.3", properties["Version"]); + Assert.Equal(properties["Version"], properties["PackageVersion"]); + } + + [Theory] + [InlineData("ContinuousIntegrationBuild", "false")] + [InlineData("OfficialBuildId", "")] + [InlineData("BUILD_REASON", "IndividualCI")] + [InlineData("BUILD_REASON", "PullRequest")] + [InlineData("BUILD_REASON", "")] + [InlineData("BUILD_SOURCEBRANCH", "refs/heads/feature")] + [InlineData("BUILD_SOURCEBRANCH", "")] + [InlineData("BUILD_REPOSITORY_PROVIDER", "GitHub")] + [InlineData("BUILD_REPOSITORY_NAME", "NuGet/Client.Tools")] + [InlineData("SYSTEM_TEAMPROJECT", "public")] + [InlineData("SYSTEM_COLLECTIONURI", "https://dev.azure.com/dnceng-public/")] + public async Task Stable_requests_reject_untrusted_or_missing_inputs(string property, string value) + { + var inputs = PipelineTestContext.OfficialInputs(release: true); + inputs[property] = value; + + var result = await PipelineTestContext.Evaluate("net8.0", inputs); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("dotnet-package-skills", result.Diagnostics); + } + + [Theory] + [InlineData("12345")] + [InlineData("$(Build.BuildId)")] + [InlineData("20261301.1")] + [InlineData("20260230.1")] + [InlineData("20261005.0")] + [InlineData("20261005.100")] + [InlineData("20261005.01")] + [InlineData("20261005.x")] + public async Task Stable_requests_reject_malformed_official_build_numbers(string buildNumber) + { + var inputs = PipelineTestContext.OfficialInputs(release: true); + inputs["OfficialBuildId"] = buildNumber; + + var result = await PipelineTestContext.Evaluate("net10.0", inputs); + + Assert.NotEqual(0, result.ExitCode); + } + + [Theory] + [InlineData("release")] + [InlineData("prerelease")] + public async Task Final_version_cannot_bypass_the_default_false_release_opt_in(string finalKind) + { + var inputs = PipelineTestContext.OfficialInputs(); + inputs["DotNetFinalVersionKind"] = finalKind; + + var result = await PipelineTestContext.Evaluate("net8.0", inputs); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("DotnetPackageSkillsReleaseBuild", result.Diagnostics); + } + + [Theory] + [InlineData("yes")] + [InlineData("$(ReleaseBuild)")] + public async Task Release_opt_in_requires_a_boolean(string value) + { + var inputs = PipelineTestContext.OfficialInputs(); + inputs["DotnetPackageSkillsReleaseBuild"] = value; + + var result = await PipelineTestContext.Evaluate("net8.0", inputs); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("DotnetPackageSkillsReleaseBuild", result.Diagnostics); + } + + [Theory] + [InlineData("RuntimeIdentifier", "win-x64")] + [InlineData("Version", "0.1.0")] + [InlineData("PackageVersion", "0.2.0")] + public async Task Packaging_cannot_override_portability_or_native_version_inputs(string property, string value) + { + var result = await PipelineTestContext.Evaluate("net8.0", new Dictionary + { + [property] = value, + }); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("dotnet-package-skills", result.Diagnostics); + } + + [Theory] + [InlineData("net8.0")] + [InlineData("net10.0")] + public async Task Tool_configuration_preserves_identity_and_native_artifact_paths(string framework) + { + var properties = PipelineTestContext.Properties(await PipelineTestContext.Evaluate(framework)); + + Assert.Equal("false", properties["SignAssembly"]); + Assert.Equal("true", properties["IsPackable"]); + Assert.Equal("true", properties["IsShipping"]); + Assert.Equal("false", properties["PackageRequireLicenseAcceptance"]); + Assert.Empty(properties["RuntimeIdentifier"]); + Assert.Equal(Path.GetFullPath(PipelineTestContext.RepoRoot) + Path.DirectorySeparatorChar, properties["RepoRoot"]); + Assert.Equal(Path.Combine(PipelineTestContext.RepoRoot, "artifacts", "bin", "DotnetPackageSkills") + + Path.DirectorySeparatorChar, properties["BaseOutputPath"]); + Assert.Equal(Path.Combine(PipelineTestContext.RepoRoot, "artifacts", "obj", "DotnetPackageSkills") + + Path.DirectorySeparatorChar, properties["BaseIntermediateOutputPath"]); + } + + [Theory] + [InlineData("net8.0")] + [InlineData("net10.0")] + public async Task Tests_remain_unsigned_nonpackable_and_nonshipping(string framework) + { + var properties = PipelineTestContext.Properties(await PipelineTestContext.Evaluate( + framework, project: @"dotnet-package-skills\tests\DotnetPackageSkills.Tests.csproj")); + + Assert.Equal("false", properties["SignAssembly"]); + Assert.Equal("false", properties["IsPackable"]); + Assert.Equal("false", properties["IsShipping"]); + } +} diff --git a/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs b/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs new file mode 100644 index 0000000..c764674 --- /dev/null +++ b/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs @@ -0,0 +1,439 @@ +using System.Text; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillDescriptionReaderTests +{ + [Theory] + [InlineData("description: Use widgets safely.", "Use widgets safely.")] + [InlineData("description: Use widgets. # Not part of the description", "Use widgets.")] + [InlineData("description: 'Use widgets: safely.'", "Use widgets: safely.")] + [InlineData("description: 'Use ''quoted'' names.'", "Use 'quoted' names.")] + [InlineData("description: \"Use \\\"quoted\\\" names and \\\\ paths.\"", "Use \"quoted\" names and \\ paths.")] + [InlineData("description: 'C:\\widgets\\skills'", "C:\\widgets\\skills")] + [InlineData("description: \"First\\nSecond\\t\\u263A\\U0001F680\"", "First\nSecond\t☺🚀")] + [InlineData("description: \" Keep surrounding spaces. \"", " Keep surrounding spaces. ")] + [InlineData("description: \"Keep\\e terminal\\r controls.\"", "Keep\u001b terminal\r controls.")] + [InlineData("description: First line\n continues here", "First line continues here")] + [InlineData("description: First paragraph\n\n second paragraph", "First paragraph\nsecond paragraph")] + [InlineData("description: \"First line\n continues here\"", "First line continues here")] + [InlineData("description: >\n First line\n continues here", "First line continues here\n")] + [InlineData("description: >-\n First paragraph\n continues here\n\n Second paragraph", "First paragraph continues here\nSecond paragraph")] + [InlineData("description: >+\n First line\n continues here\n", "First line continues here\n\n")] + [InlineData("description: >-\n First line\n indented line\n Last line", "First line\n indented line\nLast line")] + [InlineData("description: |\n First line\n Second line", "First line\nSecond line\n")] + [InlineData("description: |-\n First line\n Second line", "First line\nSecond line")] + [InlineData("description: |+\n First line\n Second line\n", "First line\nSecond line\n\n")] + [InlineData("description: |2-\n Indented line\n Last line", " Indented line\nLast line")] + [InlineData("description: |-\n ---\n ...\n description: Still scalar text", "---\n...\ndescription: Still scalar text")] + [InlineData("\"description\": A quoted key works.", "A quoted key works.")] + [InlineData("{description: 'A flow mapping works.', other: [one, two]}", "A flow mapping works.")] + [InlineData("description: 42", "42")] + [InlineData("description: true", "true")] + public void A_top_level_scalar_preserves_the_YAML_description_value(string yaml, string expected) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal(expected, result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("")] + [InlineData("# Only a comment")] + [InlineData("{}")] + [InlineData("name: widget-usage")] + [InlineData("metadata:\n description: Nested descriptions are not the skill description.")] + [InlineData("items:\n - description: Nested descriptions are not the skill description.")] + [InlineData("notes: |\n description: This is other scalar text.")] + [InlineData("? [description]\n: This is a collection key, not the description key.")] + [InlineData("Description: Keys are case-sensitive.")] + [InlineData("description:")] + [InlineData("description: # Empty")] + [InlineData("description: ''")] + [InlineData("description: \" \"")] + [InlineData("description: \"\\n\\t\\r\"")] + [InlineData("description: |\n \n ")] + public void Missing_or_blank_descriptions_have_no_warning(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Null(result.Description); + Assert.Null(result.Warning); + } + + [Fact] + public void Nested_descriptions_do_not_replace_the_top_level_description() + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, """ + before: + description: Ignore this. + description: Use this description. + after: + - description: Ignore this too. + """); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Use this description.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("")] + [InlineData("# Just Markdown\n---\ndescription: Not frontmatter.\n---")] + [InlineData("\n---\ndescription: Not at the beginning.\n---")] + [InlineData(" ---\ndescription: Indented delimiters do not open frontmatter.\n---")] + [InlineData("---not-a-delimiter\ndescription: Not frontmatter.\n---")] + public void Files_without_frontmatter_have_no_description_or_warning(string source) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", source); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Null(result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("Markdown")] + [InlineData("---not-a-delimiter")] + public void A_huge_Markdown_first_line_is_not_mistaken_for_oversized_frontmatter(string prefix) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", prefix + new string('x', 128 * 1024)); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Null(result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData(false, "\n")] + [InlineData(true, "\n")] + [InlineData(false, "\r\n")] + [InlineData(true, "\r\n")] + public void UTF8_BOM_and_line_endings_preserve_multiline_description_values(bool bom, string newline) + { + using var temp = new TempDirectory(); + var file = temp.CreateFile("SKILL.md"); + File.WriteAllText(file, $"---{newline}description: |{newline} First{newline} Second{newline}---{newline}", + new UTF8Encoding(encoderShouldEmitUTF8Identifier: bom)); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("First\nSecond\n", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("---")] + [InlineData("...")] + [InlineData("--- \t")] + [InlineData("... \t")] + public void Closing_delimiters_end_parsing_before_a_large_invalid_Markdown_body(string delimiter) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", $"--- \t\ndescription: Only the header.\n{delimiter}\n" + + "description: !invalid *alias\n" + new string('[', 128 * 1024)); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Only the header.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("---")] + [InlineData("...")] + public void A_closing_delimiter_does_not_require_a_final_newline(string delimiter) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", $"---\ndescription: Complete.\n{delimiter}"); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Complete.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("description: [unfinished")] + [InlineData("description: \"unfinished")] + [InlineData("description: Value\n unexpected: mapping")] + [InlineData("description: Valid first\nlater: {unfinished")] + [InlineData("name: No description\nlater: [unfinished")] + [InlineData("description: Valid first\nlater:\n\tinvalid: indentation")] + public void Malformed_YAML_warns_instead_of_returning_partial_metadata(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md has malformed YAML frontmatter; fix the header."); + } + + [Theory] + [InlineData("description: [one, two]")] + [InlineData("description: []")] + [InlineData("description: {}")] + [InlineData("description:\n nested: mapping")] + public void Collection_descriptions_warn_instead_of_being_converted_to_text(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md description must be a YAML scalar; replace the collection with text."); + } + + [Theory] + [InlineData("Just a scalar")] + [InlineData("''")] + [InlineData("[description, value]")] + [InlineData("- description: Inside a sequence")] + public void Non_mapping_frontmatter_explains_the_expected_shape(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter must be a YAML mapping; use 'description: ...'."); + } + + [Fact] + public void Duplicate_top_level_descriptions_warn_instead_of_choosing_one() + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, "description: First\ndescription: Second"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md has duplicate description keys; keep only one top-level description."); + } + + [Theory] + [InlineData("description: !!str Text")] + [InlineData("!!map {description: Text}")] + [InlineData("description: !custom Text")] + [InlineData("description: ! Text")] + [InlineData("description: Otherwise valid\nother: !custom value")] + public void Explicit_tags_are_rejected_without_type_deserialization(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md uses explicit YAML tags; remove the tags from its frontmatter."); + } + + [Theory] + [InlineData("description: &text Anchored text")] + [InlineData("description: *missing")] + [InlineData("&root {description: Text}")] + [InlineData("description: Otherwise valid\nother: *missing")] + [InlineData("a: &a [text]\nb: &b [*a, *a]\ndescription: *b")] + [InlineData("a: &a [*a]\ndescription: Otherwise valid")] + public void Anchors_and_aliases_are_rejected_without_expansion(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md uses YAML anchors or aliases; replace them with literal values."); + } + + [Fact] + public void The_nesting_limit_allows_32_collection_levels_including_the_root_mapping() + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, "other: " + new string('[', 31) + "leaf" + new string(']', 31) + + "\ndescription: Within the limit."); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Within the limit.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData(32)] + [InlineData(5000)] + public void Excessive_nesting_warns_even_in_irrelevant_metadata(int levels) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, "description: Valid first\nother: " + + new string('[', levels) + "leaf" + new string(']', levels)); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 32 levels of nesting; simplify the header."); + } + + [Fact] + public void The_character_limit_includes_delimiters_and_allows_exactly_64_Ki_characters() + { + using var temp = new TempDirectory(); + const string prefix = "---\ndescription: "; + const string suffix = "\n---"; + var description = new string('é', 64 * 1024 - prefix.Length - suffix.Length); + temp.CreateFile("SKILL.md", prefix + description + suffix); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal(description, result.Description); + Assert.Null(result.Warning); + } + + [Fact] + public void One_character_past_the_frontmatter_limit_warns() + { + using var temp = new TempDirectory(); + const string prefix = "---\ndescription: "; + const string suffix = "\n---"; + temp.CreateFile("SKILL.md", prefix + new string('x', 64 * 1024 + 1 - prefix.Length - suffix.Length) + suffix); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_huge_single_header_line_cannot_bypass_the_read_limit(bool terminated) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", "---\ndescription: " + new string('x', 256 * 1024) + + (terminated ? "\n---\n" : "")); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Fact] + public void Many_short_header_lines_share_one_read_limit() + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", "---\n" + string.Concat(Enumerable.Repeat("# Notes\n", 10_000)) + "---\n"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Fact] + public void A_huge_opening_delimiter_line_cannot_bypass_the_read_limit() + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", "---" + new string(' ', 64 * 1024)); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Theory] + [InlineData("---")] + [InlineData("---\n")] + [InlineData("---\ndescription: Not closed.")] + [InlineData("---\ndescription: |-\n An indented delimiter is scalar content.\n ---\n")] + public void Unterminated_frontmatter_explains_how_to_close_it(string source) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", source); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter has no closing delimiter; add a closing '---' line."); + } + + [Fact] + public void A_missing_SKILL_file_warns_without_searching_subdirectories() + { + using var temp = new TempDirectory(); + temp.CreateFile("nested\\SKILL.md", "---\ndescription: Do not discover this.\n---\n"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md was not found; restore the file to read its description."); + } + + [Fact] + public void A_missing_skill_directory_warns() + { + using var temp = new TempDirectory(); + + AssertWarning(SkillDescriptionReader.Read(temp.Combine("missing")), + "SKILL.md was not found; restore the file to read its description."); + } + + [Fact] + public void An_unreadable_SKILL_path_warns() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("SKILL.md"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md could not be read; check file permissions."); + } + + [Fact] + public void A_locked_SKILL_file_warns_instead_of_throwing() + { + using var temp = new TempDirectory(); + var file = WriteFrontmatter(temp, "description: Locked."); + using var locked = new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md could not be read; check the file path, permissions, and whether it is in use."); + } + + [Fact] + public void An_invalid_skill_directory_path_warns() + { + using var temp = new TempDirectory(); + + AssertWarning(SkillDescriptionReader.Read(temp.Path + '\0'), + "SKILL.md has an invalid path; check the skill directory path."); + } + + [Theory] + [InlineData("---\r\ndescription: |\r\n Keep original bytes.\r\n---\r\n# Body\r\n", "Keep original bytes.\n", null)] + [InlineData("# Only Markdown", null, null)] + [InlineData("---\ndescription: [broken\n---\n", null, "SKILL.md has malformed YAML frontmatter; fix the header.")] + [InlineData("---\ndescription: *alias\n---\n", null, "SKILL.md uses YAML anchors or aliases; replace them with literal values.")] + public void Reading_leaves_every_file_byte_unchanged(string source, string? description, string? warning) + { + using var temp = new TempDirectory(); + var file = temp.CreateFile("SKILL.md"); + File.WriteAllText(file, source, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + var before = File.ReadAllBytes(file); + var attributes = File.GetAttributes(file); + File.SetAttributes(file, attributes | FileAttributes.ReadOnly); + try + { + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal(description, result.Description); + Assert.Equal(warning, result.Warning); + Assert.Equal(before, File.ReadAllBytes(file)); + } + finally + { + File.SetAttributes(file, attributes); + } + } + + private static string WriteFrontmatter(TempDirectory temp, string yaml) => + temp.CreateFile("SKILL.md", $"---\n{yaml}\n---\n"); + + private static void AssertWarning(SkillDescriptionResult result, string expected) + { + Assert.Null(result.Description); + Assert.Equal(expected, result.Warning); + } +} diff --git a/dotnet-package-skills/tests/SkillDiscoveryTests.cs b/dotnet-package-skills/tests/SkillDiscoveryTests.cs new file mode 100644 index 0000000..b6c09f2 --- /dev/null +++ b/dotnet-package-skills/tests/SkillDiscoveryTests.cs @@ -0,0 +1,122 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillDiscoveryTests +{ + [Fact] + public void A_package_with_one_skill_uses_its_authored_folder_name_as_the_destination() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage"); + + var skill = Assert.Single(SkillDiscovery.Discover(package, "Mockly", "1.10.0")); + + Assert.Equal("mockly-usage", skill.RelativePath); + } + + [Fact] + public void A_package_with_several_skills_keeps_each_authored_folder_name() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage", "widget-testing"); + + var skills = SkillDiscovery.Discover(package, "Contoso.Widgets", "2.3.0"); + + Assert.Equal( + ["widget-testing", "widget-usage"], + skills.Select(s => s.RelativePath)); + } + + [Fact] + public void Discover_finds_each_subdirectory_of_the_skills_folder() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage", "widget-testing"); + + var skills = SkillDiscovery.Discover(package, "Contoso.Widgets", "2.3.0"); + + Assert.Equal(["widget-testing", "widget-usage"], skills.Select(s => s.SkillName)); + } + + [Fact] + public void Discover_keeps_package_version_as_metadata_only() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Widgets", "2.0", "usage"); + + var skill = Assert.Single(SkillDiscovery.Discover(package, "Widgets", "2.0")); + + Assert.Equal("2.0", skill.PackageVersion); + Assert.Equal("usage", skill.RelativePath); + } + + [Fact] + public void Discover_returns_nothing_when_the_package_ships_no_skills_folder() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + temp.CreateFile("packages/newtonsoft.json/13.0.3/lib/net8.0/Newtonsoft.Json.dll"); + + Assert.Empty(SkillDiscovery.Discover(package, "Newtonsoft.Json", "13.0.3")); + } + + [Fact] + public void Discover_ignores_a_skill_placed_directly_in_the_skills_folder() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "widgets", "1.0.0"); + temp.CreateFile("packages/widgets/1.0.0/skills/SKILL.md", "---\nname: widgets\n---\n"); + + Assert.Empty(SkillDiscovery.Discover(package, "Widgets", "1.0.0")); + } + + [Fact] + public void Discover_ignores_subdirectories_without_a_skill_manifest() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "widgets", "1.0.0"); + temp.CreateFile("packages/widgets/1.0.0/skills/notes/readme.md", "not a skill"); + + Assert.Empty(SkillDiscovery.Discover(package, "Widgets", "1.0.0")); + } + + [Fact] + public void Discover_matches_the_skills_folder_regardless_of_casing() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "widgets", "1.0.0"); + temp.CreateFile("packages/widgets/1.0.0/Skills/usage/SKILL.md", "---\n---\n"); + + Assert.Single(SkillDiscovery.Discover(package, "Widgets", "1.0.0")); + } + + [Fact] + public void Discover_returns_nothing_for_a_package_that_is_not_on_disk() + { + using var temp = new TempDirectory(); + + Assert.Empty(SkillDiscovery.Discover(temp.Combine("nope"), "Ghost", "1.0.0")); + } + + [Theory] + [InlineData("widget-usage", true)] + [InlineData("Widget.Usage_2", true)] + [InlineData(".hidden-skill", true)] + [InlineData("...usage", true)] + [InlineData("skill name", true)] + [InlineData("..", false)] + [InlineData(".", false)] + [InlineData("...", false)] + [InlineData("....", false)] + [InlineData(".. ", false)] + [InlineData("... ", false)] + [InlineData("skill.", false)] + [InlineData("skill ", false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("a/b", false)] + [InlineData("a\\b", false)] + public void Skill_names_that_could_escape_the_destination_are_rejected(string name, bool expected) => + Assert.Equal(expected, SkillDiscovery.IsSafeSkillName(name)); +} diff --git a/dotnet-package-skills/tests/SkillInstallServiceTests.cs b/dotnet-package-skills/tests/SkillInstallServiceTests.cs new file mode 100644 index 0000000..01bbc0a --- /dev/null +++ b/dotnet-package-skills/tests/SkillInstallServiceTests.cs @@ -0,0 +1,1082 @@ +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +/// +/// Exercises the whole flow with the dotnet CLI stubbed out, so the wiring between +/// listing, path resolution, discovery, and installation is covered without a restore. +/// +public class SkillInstallServiceTests +{ + private sealed class FakeDotnet(string globalPackagesFolder, string listPackageJson) : IProcessRunner + { + public List Invocations { get; } = []; + + public ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null) + { + var line = string.Join(' ', arguments); + Invocations.Add(line); + + if (arguments.Contains("locals")) + { + return new ProcessResult(0, $"global-packages: {globalPackagesFolder}", string.Empty); + } + + if (arguments.Contains("list")) + { + return new ProcessResult(0, listPackageJson, string.Empty); + } + + if (arguments.Contains("restore")) + { + return new ProcessResult(0, "Restore succeeded.", string.Empty); + } + + throw new InvalidOperationException($"Unexpected invocation: {line}"); + } + } + + private static string Json(params (string Id, string Version)[] packages) + { + var entries = packages.Select(p => $$"""{ "id": "{{p.Id}}", "resolvedVersion": "{{p.Version}}" }"""); + + return $$""" + { + "projects": [ + { + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ {{string.Join(",", entries)}} ] } + ] + } + ] + } + """; + } + + private static InstallRequest Request(TempDirectory temp) => new() + { + Destination = ".agents/skills", + WorkingDirectory = temp.Path, + GlobalPackagesOverride = temp.Combine("packages"), + }; + + [Fact] + public void Install_copies_skills_from_packages_that_ship_them_and_ignores_the_rest() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Newtonsoft.Json", "13.0.3"))); + var result = new SkillInstallService(runner).Install(Request(temp)); + + Assert.Equal(2, result.PackagesScanned); + Assert.Equal("mockly", Assert.Single(result.Skills).RelativePath); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Target_install_rejects_incomplete_discovery_before_changing_skills(bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Existing", "1.0.0", "existing"); + var initial = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Existing", "1.0.0")))); + initial.Install(Request(temp)); + var manifestPath = temp.Combine(".agents", "skills", InstallManifest.FileName); + var before = File.ReadAllBytes(manifestPath); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"))); + var error = Assert.Throws(() => + new SkillInstallService(runner).Install(Request(temp) with { DryRun = dryRun })); + + Assert.Contains("Mockly 1.10.0", error.Message); + Assert.Contains("restore", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllBytes(manifestPath)); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "existing", "SKILL.md"))); + } + + [Fact] + public void Discovery_treats_a_package_missing_from_the_cache_as_one_without_skills() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Missing", "1.0.0")))); + + var result = service.Discover(Request(temp)); + + Assert.Equal(1, result.PackagesScanned); + Assert.Empty(result.Skills); + Assert.Empty(result.Skipped); + Assert.False(Directory.Exists(result.Destination)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Missing_target_packages_also_block_refreshing_available_skills(bool interactive) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + var package = temp.CreatePackageWithSkill("Present", "1.0.0", "present"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Present", "1.0.0")))) + .Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var previous = File.ReadAllBytes(Path.Combine(destination, "present", "SKILL.md")); + var manifest = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + File.WriteAllText(Path.Combine(package, "skills", "present", "SKILL.md"), "new source content"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Present", "1.0.0"), ("Missing", "2.0.0")))); + var discovered = service.Discover(Request(temp)); + + Assert.Throws(() => + { + if (interactive) + { + service.PrepareInteractiveInstall( + Request(temp), discovered, SkillInstallService.InstalledSkills(destination, temp.Path)); + } + else + { + service.Install(Request(temp), discovered, null); + } + }); + + Assert.Equal(previous, File.ReadAllBytes(Path.Combine(destination, "present", "SKILL.md"))); + Assert.Equal(manifest, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + } + + [Fact] + public void Preparing_a_picker_excludes_conflicting_candidates_without_losing_the_warning() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "shared"); + temp.CreatePackageWithSkill("Beta", "2.0.0", "shared"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Alpha@1.0.0")] }); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Beta@2.0.0")] }; + var discovered = service.Discover(request); + var installed = SkillInstallService.InstalledSkills(discovered.Destination, temp.Path); + + var prepared = service.PrepareInteractiveInstall(request, discovered, installed); + var result = service.Install(request, prepared, + new SkillChoice([]) { ExpectedInstalled = installed }); + + Assert.Empty(prepared.Skills); + Assert.Contains("managed for alpha", Assert.Single(result.Skipped).Reason); + Assert.Empty(result.Removed); + Assert.False(result.DryRun); + Assert.Equal("alpha", Assert.Single(InstallManifest.Load(result.Destination).Packages).Key); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void An_interactive_target_install_stops_while_installed_skills_are_stale(bool versionChanged) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly", "mockly-testing"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))).Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + versionChanged + ? Json(("Mockly", "1.11.0"), ("Contoso.Widgets", "2.3.0")) + : Json(("Mockly", "1.10.0")))); + var discovered = service.Discover(Request(temp)); + + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + Request(temp), discovered, SkillInstallService.InstalledSkills(destination, temp.Path))); + + Assert.Contains( + versionChanged + ? "1 installed skill doesn't match the target: mockly (mockly 1.10.0)" + : "1 installed skill doesn't match the target: widget-usage (contoso.widgets 2.3.0)", + error.Message); + Assert.Contains("uninstall --stale", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void An_interactive_install_offers_only_the_skills_that_are_not_installed() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + var first = service.Discover(Request(temp)); + service.Install(Request(temp), first, new SkillChoice([.. first.Skills.Where(skill => skill.SkillName == "mockly-usage")])); + var destination = temp.Combine(".agents", "skills"); + var installed = SkillInstallService.InstalledSkills(destination, temp.Path); + + var prepared = service.PrepareInteractiveInstall(Request(temp), service.Discover(Request(temp)), installed); + + Assert.Equal("mockly-testing", Assert.Single(prepared.Skills).SkillName); + Assert.Empty(prepared.Removed); + Assert.Empty(prepared.Unreferenced); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-testing"))); + } + + [Fact] + public void An_interactive_install_with_nothing_new_offers_an_empty_list() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + var installed = SkillInstallService.InstalledSkills(temp.Combine(".agents", "skills"), temp.Path); + + var prepared = service.PrepareInteractiveInstall(Request(temp), service.Discover(Request(temp)), installed); + + Assert.Empty(prepared.Skills); + Assert.Equal(1, prepared.SkillsDiscovered); + Assert.Empty(prepared.Skipped); + } + + [Fact] + public void An_interactive_install_of_a_named_package_stops_when_another_version_is_installed() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }; + + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + request, service.Discover(request), SkillInstallService.InstalledSkills(destination, temp.Path))); + + Assert.Contains("Mockly 1.10.0 is already installed", error.Message); + Assert.Contains("'dotnet-package-skills uninstall --package Mockly'", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Theory] + [InlineData(".agents/skills", null, "--stale", true, "dotnet-package-skills uninstall --stale")] + [InlineData(".agents/skills/", null, "--stale", true, "dotnet-package-skills uninstall --stale")] + [InlineData(".claude/skills", null, "--stale", true, "dotnet-package-skills uninstall --stale --destination .claude/skills")] + [InlineData( + "my skills", "src/My App.slnx", "--stale", true, + "dotnet-package-skills uninstall --stale --target \"src/My App.slnx\" --destination \"my skills\"")] + [InlineData( + ".claude/skills", "src/App.slnx", "--package Mockly", false, + "dotnet-package-skills uninstall --package Mockly --destination .claude/skills")] + [InlineData( + @"C:\src\skills", null, "--stale", true, + "dotnet-package-skills uninstall --stale --destination \"C:\\src\\skills\"")] + public void Suggested_commands_repeat_the_target_and_destination_that_were_used( + string destination, string? target, string arguments, bool withTarget, string expected) + { + // A suggestion is only useful if running it as printed acts on the same skills folder, + // compared against the same project. + using var temp = new TempDirectory(); + var request = Request(temp) with { Destination = destination, Target = target }; + + Assert.Equal(expected, SkillInstallService.UninstallCommand(request, arguments, withTarget)); + } + + [Fact] + public void A_suggested_command_leaves_out_a_destination_that_is_the_default_spelled_in_full() + { + using var temp = new TempDirectory(); + var request = Request(temp) with { Destination = temp.Combine(".agents", "skills") }; + + Assert.Equal("dotnet-package-skills uninstall --stale", SkillInstallService.UninstallCommand(request, "--stale")); + } + + [Fact] + public void The_stale_hint_and_the_stale_stop_name_the_destination_that_was_used() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + var request = Request(temp) with { Destination = ".claude/skills" }; + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))).Install(request); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + + var result = service.Install(request); + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + request, service.Discover(request), SkillInstallService.InstalledSkills(".claude/skills", temp.Path))); + + const string Command = "dotnet-package-skills uninstall --stale --destination .claude/skills"; + Assert.Equal(Command, result.StaleCommand); + Assert.Contains($"Run '{Command}' first", error.Message); + } + + [Fact] + public void The_other_version_stop_names_the_destination_that_was_used() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with { Destination = ".claude/skills" }; + service.Install(request with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + var upgrade = request with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }; + + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + upgrade, service.Discover(upgrade), SkillInstallService.InstalledSkills(".claude/skills", temp.Path))); + + Assert.Contains("'dotnet-package-skills uninstall --package Mockly --destination .claude/skills' first", error.Message); + } + + [Fact] + public void A_version_change_that_would_hand_a_skill_to_another_package_stops_with_a_command_for_this_destination() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "alpha-usage", "shared"); + temp.CreatePackageWithSkill("Alpha", "2.0.0", "alpha-usage"); + temp.CreatePackageWithSkill("Beta", "1.0.0", "shared"); + var request = Request(temp) with { Destination = ".claude/skills" }; + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Alpha", "1.0.0")))).Install(request); + var destination = temp.Combine(".claude", "skills"); + var before = Snapshot(destination); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Alpha", "2.0.0"), ("Beta", "1.0.0")))); + + var error = Assert.Throws(() => service.Install(request)); + + Assert.Contains("Alpha 2.0.0 no longer ships the installed skill 'shared'", error.Message); + Assert.Contains("'dotnet-package-skills uninstall --package Alpha --destination .claude/skills' first", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void An_interactive_install_of_a_named_package_ignores_other_installed_packages() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Contoso.Widgets@2.3.0")] }); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }; + var installed = SkillInstallService.InstalledSkills(temp.Combine(".agents", "skills"), temp.Path); + + var prepared = service.PrepareInteractiveInstall(request, service.Discover(request), installed); + + Assert.Equal("mockly", Assert.Single(prepared.Skills).SkillName); + } + + [Fact] + public void A_package_that_disappears_after_the_preview_also_blocks_acceptance() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Present", "1.0.0", "present"); + var emptyPackage = temp.CreateDirectory("packages", "empty", "1.0.0"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Present", "1.0.0"), ("Empty", "1.0.0")))); + var discovered = service.Discover(Request(temp)); + var prepared = service.PrepareInteractiveInstall(Request(temp), discovered, []); + Directory.Delete(emptyPackage); + + var error = Assert.Throws(() => + service.Install(Request(temp), prepared, new SkillChoice(prepared.Skills))); + + Assert.Contains("Empty 1.0.0", error.Message); + Assert.False(Directory.Exists(prepared.Destination)); + } + + [Fact] + public void Installation_prefers_the_current_owners_upgrade_over_an_earlier_named_collision() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Zeta", "1.0.0", "shared"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Zeta", "1.0.0")))) + .Install(Request(temp)); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "shared"); + var newer = temp.CreatePackageWithSkill("Zeta", "2.0.0", "shared"); + File.WriteAllText(Path.Combine(newer, "skills", "shared", "SKILL.md"), "updated owner"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Alpha", "1.0.0"), ("Zeta", "2.0.0")))); + + var discovered = service.Discover(Request(temp)); + Assert.Equal("Alpha", Assert.Single(discovered.Skills).PackageId); + var result = service.Install(Request(temp), discovered, choice: null); + + Assert.Equal("Zeta", Assert.Single(result.Skills).PackageId); + Assert.Equal("Alpha", Assert.Single(result.Skipped).PackageId); + Assert.Empty(result.Removed); + Assert.Equal("updated owner", File.ReadAllText(Path.Combine(result.Destination, "shared", "SKILL.md"))); + Assert.Equal("2.0.0", Assert.Single(InstallManifest.Load(result.Destination).Packages).Value.Version); + } + + [Fact] + public void Repeating_equivalent_package_coordinates_does_not_create_self_collisions() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "alpha"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + + var result = service.Install(Request(temp) with + { + Packages = [ + PackageCoordinate.Parse("Alpha@1.0"), + PackageCoordinate.Parse("alpha@1.0.0"), + PackageCoordinate.Parse("Alpha@1.0.0.0"), + ], + }); + + Assert.Equal(1, result.PackagesScanned); + Assert.Single(result.Skills); + Assert.Empty(result.Skipped); + } + + [Fact] + public void Install_auto_detects_the_solution_when_no_target_is_given() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + var result = new SkillInstallService(runner).Install(Request(temp)); + + Assert.EndsWith("MyApp.sln", result.Target); + } + + [Fact] + public void Install_honours_a_custom_destination() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"))); + new SkillInstallService(runner).Install(Request(temp) with { Destination = ".claude/skills" }); + + Assert.True(File.Exists(temp.Combine(".claude", "skills", "mockly", "SKILL.md"))); + } + + [Fact] + public void Discover_does_not_write_to_the_destination() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"))); + var result = new SkillInstallService(runner).Discover(Request(temp)); + + Assert.Single(result.Skills); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Fact] + public void Install_never_requests_transitive_packages() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + new SkillInstallService(runner).Install(Request(temp)); + + Assert.DoesNotContain(runner.Invocations, line => line.Contains("--include-transitive")); + } + + [Fact] + public void Install_passes_the_target_before_the_package_verb() + { + // `dotnet list package` is the required order; the reverse silently + // lists the packages of whatever project is in the current directory instead. + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + new SkillInstallService(runner).Install(Request(temp)); + + var listCall = Assert.Single(runner.Invocations, line => line.StartsWith("list", StringComparison.Ordinal)); + Assert.Matches(@"^list .*MyApp\.sln package ", listCall); + } + + [Fact] + public void An_upgrade_replaces_the_previous_version_end_to_end() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + var upgraded = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.11.0")))); + var result = upgraded.Install(Request(temp)); + + Assert.Empty(result.Removed); + Assert.True(Directory.Exists(temp.Combine(".agents", "skills", "mockly"))); + Assert.Equal( + "1.11.0", + Assert.Single(InstallManifest.Load(temp.Combine(".agents", "skills")).Packages).Value.Version); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void A_solution_whose_projects_disagree_on_a_version_stops_every_install_mode(bool interactive, bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + + const string json = """ + { + "projects": [ + { + "path": "/repo/src/Api/Api.csproj", + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ { "id": "Mockly", "resolvedVersion": "1.10.0" } ] } + ] + }, + { + "path": "/repo/src/Worker/Worker.csproj", + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ { "id": "Mockly", "resolvedVersion": "1.11.0" } ] } + ] + } + ] + } + """; + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), json)); + var request = Request(temp) with { DryRun = dryRun }; + + var error = Assert.Throws(() => + { + if (interactive) + { + service.PrepareInteractiveInstall( + request, service.Discover(request), SkillInstallService.InstalledSkills(destination, temp.Path)); + } + else + { + service.Install(request); + } + }); + + Assert.Contains("Mockly (1.10.0, 1.11.0)", error.Message); + Assert.Contains("Central Package Management", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void Two_versions_of_a_package_that_ships_no_skills_also_stop_the_install() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreateDirectory("packages", "newtonsoft.json", "12.0.3"); + temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + Json(("Mockly", "1.10.0"), ("Newtonsoft.Json", "12.0.3"), ("Newtonsoft.Json", "13.0.3")))); + + var error = Assert.Throws(() => service.Install(Request(temp))); + + Assert.Contains("Newtonsoft.Json (12.0.3, 13.0.3)", error.Message); + Assert.DoesNotContain("Mockly", error.Message); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Fact] + public void List_still_shows_what_each_version_ships_when_a_package_has_two() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Mockly", "1.11.0")))); + + var result = service.Discover(Request(temp)); + + Assert.Equal(["mockly", "mockly-testing"], result.Skills.Select(skill => skill.SkillName)); + Assert.Equal("1.11.0", Assert.Single(result.Skipped).PackageVersion); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Naming_one_package_with_two_versions_stops_the_install(bool interactive) + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with + { + Packages = [PackageCoordinate.Parse("Mockly@1.10.0"), PackageCoordinate.Parse("mockly@1.11")], + }; + + var error = Assert.Throws(() => + { + if (interactive) + { + service.PrepareInteractiveInstall(request, service.Discover(request), []); + } + else + { + service.Install(request); + } + }); + + Assert.Contains("Mockly (1.10.0, 1.11)", error.Message); + Assert.Contains("one version per package", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Fact] + public void Skills_from_different_packages_that_share_a_name_keep_the_first_and_warn() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Alpha.Widgets", "1.0.0", "shared-skill"); + temp.CreatePackageWithSkill("Beta.Widgets", "1.0.0", "SHARED-SKILL"); + + var runner = new FakeDotnet( + temp.Combine("packages"), + Json(("Beta.Widgets", "1.0.0"), ("Alpha.Widgets", "1.0.0"))); + var result = new SkillInstallService(runner).Install(Request(temp)); + + Assert.Equal("Alpha.Widgets", Assert.Single(result.Skills).PackageId); + Assert.Equal("Beta.Widgets", Assert.Single(result.Skipped).PackageId); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "shared-skill", "SKILL.md"))); + } + + [Fact] + public void Install_takes_skills_from_an_explicitly_named_package_without_a_project() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + // No solution or project exists in the temp directory at all. + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + var result = new SkillInstallService(runner).Install( + Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + + Assert.Null(result.Target); + Assert.Equal("mockly", Assert.Single(result.Skills).RelativePath); + Assert.DoesNotContain(runner.Invocations, line => line.StartsWith("list", StringComparison.Ordinal)); + } + + [Fact] + public void Naming_a_package_explicitly_does_not_prune_skills_installed_from_a_project() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + // Naming one package says nothing about the others, so this must be additive. + var result = service.Install( + Request(temp) with { Packages = [PackageCoordinate.Parse("Contoso.Widgets@2.3.0")] }); + + Assert.Empty(result.Removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "widget-usage", "SKILL.md"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_target_install_keeps_skills_whose_package_left_the_project_and_reports_them(bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))).Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + + var result = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp) with { DryRun = dryRun }); + + Assert.Empty(result.Removed); + Assert.Equal(new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage"), Assert.Single(result.Unreferenced)); + Assert.True(File.Exists(Path.Combine(destination, "widget-usage", "SKILL.md"))); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void A_target_upgrade_removes_the_skills_the_new_version_dropped() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-migration"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly-usage"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp)); + + var result = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.11.0")))) + .Install(Request(temp)); + + Assert.Equal("mockly-usage", Assert.Single(result.Skills).SkillName); + Assert.Equal(new TrackedSkill("mockly", "1.10.0", "mockly-migration"), Assert.Single(result.Removed)); + Assert.Empty(result.Unreferenced); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "mockly-migration"))); + Assert.Equal("1.11.0", InstallManifest.Load(result.Destination).Packages["mockly"].Version); + } + + [Fact] + public void Naming_a_newer_version_upgrades_that_package_and_leaves_the_others_alone() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-migration"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly-usage"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))); + service.Install(Request(temp)); + + var result = service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }); + + Assert.Equal("mockly-migration", Assert.Single(result.Removed).Skill); + Assert.Empty(result.Unreferenced); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "widget-usage", "SKILL.md"))); + var manifest = InstallManifest.Load(result.Destination); + Assert.Equal("1.11.0", manifest.Packages["mockly"].Version); + Assert.Equal("2.3.0", manifest.Packages["contoso.widgets"].Version); + } + + [Fact] + public void A_named_version_missing_from_the_cache_never_removes_anything() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-migration"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + + var result = service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }); + + Assert.Empty(result.Skills); + Assert.Empty(result.Removed); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void An_explicitly_named_package_missing_from_the_cache_installs_nothing_without_an_error() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + var result = new SkillInstallService(runner).Install( + Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@9.9.9")] }); + + Assert.Equal(1, result.PackagesScanned); + Assert.Empty(result.Skills); + Assert.Empty(result.Skipped); + Assert.False(Directory.Exists(result.Destination)); + } + + [Fact] + public void A_selection_can_take_only_some_of_one_packages_skills() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill( + "Mockly", + "1.10.0", + "mockly-assertions", + "mockly-testing", + "mockly-usage", + "mockly-verification"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }; + + var discovered = service.Discover(request); + Assert.Equal(4, discovered.Skills.Count); + + var chosen = discovered.Skills + .Where(skill => skill.RelativePath is "mockly-assertions" or "mockly-usage") + .ToList(); + + var result = service.Install(request, discovered, new SkillChoice(chosen)); + + Assert.Equal( + ["mockly-assertions", "mockly-usage"], + result.Skills.Select(skill => skill.RelativePath)); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "mockly-testing"))); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "mockly-verification"))); + } + + [Fact] + public void A_selection_installs_only_the_skills_that_were_chosen() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))); + + var request = Request(temp); + var discovered = service.Discover(request); + var chosen = discovered.Skills.Where(skill => skill.RelativePath == "mockly").ToList(); + + var result = service.Install(request, discovered, new SkillChoice(chosen)); + + Assert.Equal("mockly", Assert.Single(result.Skills).RelativePath); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "widget-usage"))); + } + + [Fact] + public void A_selection_never_removes_an_installed_skill_it_does_not_mention() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with + { + Packages = + [ + PackageCoordinate.Parse("Mockly@1.10.0"), + PackageCoordinate.Parse("Contoso.Widgets@2.3.0"), + ], + }; + + service.Install(request); + + var discovered = service.Discover(request); + var keep = discovered.Skills.Where(skill => skill.RelativePath == "mockly").ToList(); + + var result = service.Install(request, discovered, new SkillChoice(keep)); + + Assert.Empty(result.Removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "widget-usage", "SKILL.md"))); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + } + + [Fact] + public void A_selection_in_a_dry_run_writes_nothing() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + var request = Request(temp) with { DryRun = true }; + var discovered = service.Discover(request); + + var result = service.Install(request, discovered, new SkillChoice(discovered.Skills)); + + Assert.Single(result.Skills); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_selection_never_removes_skills_the_packages_no_longer_offer(bool noCandidates) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "current", "stale"); + var initial = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + initial.Install(Request(temp)); + var service = noCandidates + ? new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())) + : initial; + if (!noCandidates) + { + Directory.Delete(temp.Combine("packages", "mockly", "1.10.0", "skills", "stale"), recursive: true); + } + + var discovered = service.Discover(Request(temp)); + var result = service.Install(Request(temp), discovered, new SkillChoice(discovered.Skills)); + + Assert.Empty(result.Removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "current", "SKILL.md"))); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "stale", "SKILL.md"))); + Assert.Equal(2, InstallManifest.Load(result.Destination).EnumerateSkills().Count()); + } + + [Fact] + public void Installed_skill_names_are_read_from_the_destination_manifest() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + var installed = SkillInstallService.InstalledSkillNames(temp.Combine(".agents", "skills")); + + Assert.Contains("mockly", installed); + // Destination names compare case-insensitively everywhere else, so they must here too. + Assert.Contains("MOCKLY", installed); + } + + [Fact] + public void Installed_skill_names_are_empty_for_a_destination_that_does_not_exist_yet() + { + using var temp = new TempDirectory(); + + Assert.Empty(SkillInstallService.InstalledSkillNames(temp.Combine("nowhere"))); + } + + [Fact] + public void Uninstall_version_filter_leaves_another_version_installed() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with + { + Packages = [PackageCoordinate.Parse("Mockly@1.11.0")], + }); + + service.Uninstall(".agents/skills", temp.Path, "Mockly", "1.10.0", dryRun: false); + + Assert.True(Directory.Exists(temp.Combine(".agents", "skills", "mockly"))); + } + + [Fact] + public void Uninstall_reverses_an_install() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + var removed = service.Uninstall(".agents/skills", temp.Path, packageId: null, packageVersion: null, dryRun: false); + + Assert.Single(removed); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Uninstall_stale_removes_skills_whose_package_left_the_target_or_changed_version(bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "alpha"); + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0"), ("Alpha", "1.0.0")))).Install(Request(temp)); + temp.CreateFile(".agents/skills/team-notes/SKILL.md", "ours"); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + // The cache is gone too: deciding what is stale needs only the references. + Directory.Delete(temp.Combine("packages"), recursive: true); + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.11.0"), ("Alpha", "1.0.0"))); + var service = new SkillInstallService(runner); + + var references = service.ReadReferences(target: null, temp.Path); + var removed = service.Uninstall( + ".agents/skills", temp.Path, packageId: null, packageVersion: null, dryRun, staleAgainst: references.Packages); + + Assert.EndsWith("MyApp.sln", references.Target); + Assert.Equal( + [ + new TrackedSkill("mockly", "1.10.0", "mockly"), + new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage"), + ], + removed); + Assert.DoesNotContain(runner.Invocations, line => line.Contains("locals", StringComparison.Ordinal)); + if (dryRun) + { + Assert.Equal(before, Snapshot(destination)); + } + else + { + Assert.False(Directory.Exists(Path.Combine(destination, "mockly"))); + Assert.False(Directory.Exists(Path.Combine(destination, "widget-usage"))); + Assert.True(File.Exists(Path.Combine(destination, "alpha", "SKILL.md"))); + Assert.Equal("ours", File.ReadAllText(Path.Combine(destination, "team-notes", "SKILL.md"))); + Assert.Equal("alpha", Assert.Single(InstallManifest.Load(destination).Packages).Key); + } + } + + [Fact] + public void Uninstall_stale_keeps_a_skill_whose_installed_version_is_still_referenced_beside_another() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp)); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Mockly", "1.11.0")))); + + var references = service.ReadReferences(target: null, temp.Path); + var removed = service.Uninstall( + ".agents/skills", temp.Path, null, null, dryRun: false, staleAgainst: references.Packages); + + Assert.Empty(removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + } + + [Fact] + public void Deciding_what_is_stale_requires_a_solution_or_project() + { + using var temp = new TempDirectory(); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + + var error = Assert.Throws(() => + service.ReadReferences(target: null, temp.Path)); + + Assert.Contains("--target", error.Message); + } + + private static (string Path, string Contents)[] Snapshot(string root) => + Directory.Exists(root) + ? + [ + .. Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => (Path.GetRelativePath(root, path), Convert.ToHexString(File.ReadAllBytes(path)))), + ] + : []; +} diff --git a/dotnet-package-skills/tests/SkillInstallerTests.cs b/dotnet-package-skills/tests/SkillInstallerTests.cs new file mode 100644 index 0000000..bab35a0 --- /dev/null +++ b/dotnet-package-skills/tests/SkillInstallerTests.cs @@ -0,0 +1,943 @@ +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillInstallerTests +{ + private readonly SkillInstaller _installer = new(); + + private static BundledSkill Skill( + TempDirectory temp, + string packageId, + string version, + string skillName) + { + var packageDirectory = temp.CreatePackageWithSkill(packageId, version, skillName); + + return new BundledSkill( + packageId, + version, + skillName, + Path.Combine(packageDirectory, "skills", skillName), + skillName); + } + + [Fact] + public void Install_copies_a_skill_to_its_authored_folder_name() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + Assert.True(File.Exists(Path.Combine(destination, "mockly", "SKILL.md"))); + } + + [Fact] + public void Install_copies_nested_files_such_as_references() + { + using var temp = new TempDirectory(); + var skill = Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"); + temp.CreateFile("packages/contoso.widgets/2.3.0/skills/widget-usage/references/batching.md", "rules"); + + _installer.Install(temp.Combine("dest"), [skill], dryRun: false); + + Assert.Equal( + "rules", + File.ReadAllText(temp.Combine("dest", "widget-usage", "references", "batching.md"))); + } + + [Fact] + public void Manifest_groups_skill_names_by_package_and_version() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install( + destination, + [ + Skill(temp, "Contoso.Widgets", "2.3.0", "contoso.widgets-widget-testing"), + Skill(temp, "Contoso.Widgets", "2.3.0", "contoso.widgets-widget-usage"), + Skill(temp, "Mockly", "1.10.0", "mockly"), + ], + dryRun: false); + + var manifest = InstallManifest.Load(destination); + Assert.Equal( + ["contoso.widgets-widget-testing", "contoso.widgets-widget-usage"], + manifest.Packages["contoso.widgets"].Skills); + + var json = File.ReadAllText(Path.Combine(destination, InstallManifest.FileName)); + Assert.Contains("\"packages\":", json); + Assert.Contains("\"skills\":", json); + Assert.DoesNotContain("\"path\":", json); + Assert.DoesNotContain("\"skill\":", json); + } + + [Fact] + public void Install_with_dryRun_writes_nothing() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: true); + + Assert.Single(outcome.Installed); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Install_removes_the_previous_version_when_a_package_is_upgraded() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.11.0", "mockly")], dryRun: false); + + Assert.True(Directory.Exists(Path.Combine(destination, "mockly"))); + Assert.Empty(outcome.Removed); + Assert.Equal("1.11.0", Assert.Single(InstallManifest.Load(destination).Packages).Value.Version); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void An_upgrade_removes_the_skills_the_new_version_no_longer_ships(bool dryRun) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly-usage"), Skill(temp, "Mockly", "1.10.0", "mockly-migration")], + dryRun: false); + var before = Snapshot(destination); + + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.11.0", "mockly-usage")], dryRun); + + Assert.Equal("mockly-usage", Assert.Single(outcome.Installed).SkillName); + Assert.Equal(new TrackedSkill("mockly", "1.10.0", "mockly-migration"), Assert.Single(outcome.Removed)); + if (dryRun) + { + Assert.Equal(before, Snapshot(destination)); + } + else + { + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-migration"))); + var package = Assert.Single(InstallManifest.Load(destination).Packages); + Assert.Equal("1.11.0", package.Value.Version); + Assert.Equal(["mockly-usage"], package.Value.Skills); + } + } + + [Fact] + public void An_upgrade_to_a_version_without_skills_removes_every_skill_of_that_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly-usage"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + + var outcome = _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "1.11.0"))); + + Assert.Equal("mockly-usage", Assert.Single(outcome.Removed).Skill); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-usage"))); + Assert.Equal("contoso.widgets", Assert.Single(InstallManifest.Load(destination).Packages).Key); + } + + [Fact] + public void The_same_version_never_removes_a_tracked_skill_it_does_not_ship() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly-usage"), Skill(temp, "Mockly", "1.10.0", "mockly-migration")], + dryRun: false); + + // A package version never changes, so a skill missing from it means the cache is not + // what it was. Guessing that the author removed the skill would delete it on a hunch. + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly-usage")], dryRun: false); + + Assert.Empty(outcome.Removed); + Assert.True(File.Exists(Path.Combine(destination, "mockly-migration", "SKILL.md"))); + Assert.Equal( + ["mockly-migration", "mockly-usage"], + InstallManifest.Load(destination).Packages["mockly"].Skills); + } + + [Fact] + public void Skills_of_packages_the_run_does_not_offer_are_kept_and_reported_as_untouched() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + Assert.Empty(outcome.Removed); + Assert.Equal(new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage"), Assert.Single(outcome.Untouched)); + Assert.True(File.Exists(Path.Combine(destination, "widget-usage", "SKILL.md"))); + Assert.Equal(["contoso.widgets", "mockly"], InstallManifest.Load(destination).Packages.Keys); + } + + [Fact] + public void Install_keeps_the_first_skill_when_destination_names_collide() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var outcome = _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "shared-skill"), + Skill(temp, "Contoso.Widgets", "2.3.0", "shared-skill"), + ], + dryRun: false); + + Assert.Equal("Mockly", Assert.Single(outcome.Installed).PackageId); + Assert.Equal("Contoso.Widgets", Assert.Single(outcome.Skipped).PackageId); + Assert.True(File.Exists(Path.Combine(destination, "shared-skill", "SKILL.md"))); + } + + [Fact] + public void Install_detects_destination_collisions_case_insensitively() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var outcome = _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "shared-skill"), + Skill(temp, "Contoso.Widgets", "2.3.0", "SHARED-SKILL"), + ], + dryRun: false); + + Assert.Single(outcome.Installed); + Assert.Single(outcome.Skipped); + } + + [Fact] + public void Install_with_nothing_to_record_leaves_no_folder_behind() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + // Most packages ship no skills, so this is the common outcome. It should not leave an + // empty skills folder in a repository that never had one. + _installer.Install(destination, [], dryRun: false); + + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Removing_the_last_tracked_skill_on_an_upgrade_removes_the_manifest_like_uninstall_does() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "1.11.0"))); + + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Removing_everything_still_keeps_a_folder_holding_hand_authored_skills() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var handAuthored = temp.CreateFile("dest/our-own-skill/SKILL.md", "ours"); + + _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "1.11.0"))); + + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + Assert.Equal("ours", File.ReadAllText(handAuthored)); + } + + [Fact] + public void Install_never_touches_skills_it_did_not_install() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + // Removal is driven by the manifest, so a hand-authored skill sitting alongside + // package-provided ones has to survive every install. + Directory.CreateDirectory(destination); + var handAuthored = Path.Combine(destination, "our-own-skill"); + Directory.CreateDirectory(handAuthored); + File.WriteAllText(Path.Combine(handAuthored, "SKILL.md"), "ours"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "2.0.0"))); + + Assert.True(File.Exists(Path.Combine(handAuthored, "SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly"))); + } + + [Fact] + public void Install_skips_an_existing_untracked_destination_folder() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var existing = temp.CreateFile("dest/mockly/SKILL.md", "ours"); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly")], + dryRun: false); + + Assert.Empty(outcome.Installed); + Assert.Single(outcome.Skipped); + Assert.Equal("ours", File.ReadAllText(existing)); + } + + [Fact] + public void Install_skips_an_existing_file_at_the_destination_path() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var existing = temp.CreateFile("dest/mockly", "ours"); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly")], + dryRun: false); + + Assert.Empty(outcome.Installed); + Assert.Single(outcome.Skipped); + Assert.Equal("ours", File.ReadAllText(existing)); + } + + [Fact] + public void Additive_install_skips_a_path_tracked_for_another_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "shared-skill")], + dryRun: false); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "shared-skill")], + dryRun: false, + offered: Offer()); + + Assert.Empty(outcome.Installed); + Assert.Single(outcome.Skipped); + Assert.Equal("contoso.widgets", Assert.Single(InstallManifest.Load(destination).Packages).Key); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Complete_install_preserves_a_path_owned_by_another_package(bool dryRun) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "shared-skill")], + dryRun: false); + var contents = File.ReadAllBytes(Path.Combine(destination, "shared-skill", "SKILL.md")); + var manifest = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "shared-skill")], + dryRun); + + Assert.Empty(outcome.Installed); + Assert.Empty(outcome.Removed); + Assert.Contains("managed for contoso.widgets", Assert.Single(outcome.Skipped).Reason); + Assert.Equal("contoso.widgets", Assert.Single(InstallManifest.Load(destination).Packages).Key); + Assert.Equal(contents, File.ReadAllBytes(Path.Combine(destination, "shared-skill", "SKILL.md"))); + Assert.Equal(manifest, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Moving_a_package_to_a_version_without_a_skill_that_another_package_ships_stops_before_any_change( + bool dryRun) + { + // Removing the old copy would hand its name to the other package, which takes an explicit + // uninstall. Keeping it would record the old version's copy under the new version, where + // no later run would ever remove it. + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Alpha", "1.0.0", "alpha-usage"), Skill(temp, "Alpha", "1.0.0", "shared")], + dryRun: false); + var before = Snapshot(destination); + + var error = Assert.Throws(() => _installer.Install( + destination, + [Skill(temp, "Alpha", "2.0.0", "alpha-usage"), Skill(temp, "Beta", "2.0.0", "shared")], + dryRun, + offered: Offer(("Alpha", "2.0.0"), ("Beta", "2.0.0")))); + + Assert.Contains( + "Alpha 2.0.0 no longer ships the installed skill 'shared', and Beta 2.0.0 ships a skill with that name", + error.Message); + Assert.Contains("'dotnet-package-skills uninstall --package Alpha' first", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void A_conflicting_copy_keeps_its_owner_when_the_owners_package_is_not_part_of_the_run() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Alpha", "1.0.0", "shared")], dryRun: false); + + var result = _installer.Install( + destination, + [Skill(temp, "Beta", "2.0.0", "shared")], + dryRun: false, + offered: Offer(("Beta", "2.0.0"))); + + Assert.Empty(result.Installed); + Assert.Empty(result.Removed); + Assert.Contains("managed for alpha 1.0.0", Assert.Single(result.Skipped).Reason); + Assert.Equal(new TrackedSkill("alpha", "1.0.0", "shared"), Assert.Single(result.Untouched)); + var alpha = InstallManifest.Load(destination).Packages["alpha"]; + Assert.Equal("1.0.0", alpha.Version); + Assert.Equal(["shared"], alpha.Skills); + } + + [Fact] + public void A_package_id_with_letters_outside_ascii_is_recorded_in_a_manifest_that_reads_back() + { + // NuGet accepts any Unicode letter in a package ID. A manifest that the tool's own reader + // refused would stop every later install and uninstall with advice that can't help. + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install(destination, [Skill(temp, "Contoso.Überlib", "1.0.0", "uber-usage")], dryRun: false); + + Assert.Equal("contoso.überlib", Assert.Single(InstallManifest.Load(destination).Packages).Key); + Assert.Single(_installer.Uninstall(destination, "Contoso.Überlib", null, dryRun: false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_changed_owner_invalidates_an_interactive_selection_before_writing(bool uninstall) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Alpha", "1.0.0", "shared")], dryRun: false); + var observed = InstallManifest.Load(destination).EnumerateSkills().ToList(); + _installer.Uninstall(destination, null, null, dryRun: false); + _installer.Install(destination, [Skill(temp, "Beta", "2.0.0", "shared")], dryRun: false); + var before = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var error = Assert.Throws(() => + { + if (uninstall) + { + _installer.Uninstall(destination, null, null, false, ["shared"], observed); + } + else + { + _installer.Install(destination, [], dryRun: false, offered: Offer(), expectedInstalled: observed); + } + }); + + Assert.Contains("ownership changed", error.Message); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + Assert.True(File.Exists(Path.Combine(destination, "shared", "SKILL.md"))); + } + + [Fact] + public void Upgrading_one_package_leaves_the_other_packages_untouched() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Alpha", "1.0.0", "shared"), Skill(temp, "Beta", "1.0.0", "beta")], + dryRun: false); + + var result = _installer.Install(destination, [Skill(temp, "Alpha", "2.0.0", "shared")], dryRun: false); + + Assert.Empty(result.Skipped); + Assert.Single(result.Installed); + var manifest = InstallManifest.Load(destination); + Assert.Equal("2.0.0", manifest.Packages["alpha"].Version); + Assert.Equal("1.0.0", manifest.Packages["beta"].Version); + } + + [Fact] + public void An_only_adding_install_cannot_record_a_second_version_of_a_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly-usage")], dryRun: false); + var before = Snapshot(destination); + + var error = Assert.Throws(() => _installer.Install( + destination, [Skill(temp, "Mockly", "1.11.0", "mockly-testing")], dryRun: false, offered: Offer())); + + Assert.Contains("only one version", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_source_that_disappears_after_discovery_blocks_all_writes(bool dryRun) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Old", "1.0.0", "old")], dryRun: false); + var first = Skill(temp, "Alpha", "1.0.0", "first"); + var missing = Skill(temp, "Beta", "1.0.0", "missing"); + Directory.Delete(missing.SourcePath, recursive: true); + var before = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var error = Assert.Throws(() => + _installer.Install(destination, [first, missing], dryRun: dryRun)); + + Assert.Contains("no longer available", error.Message); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + Assert.True(File.Exists(Path.Combine(destination, "old", "SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(destination, "first"))); + } + + [Fact] + public void An_upgrade_reports_removed_tracking_entries_even_when_the_folder_was_already_deleted() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Alpha", "1.0.0", "gone")], dryRun: false); + Directory.Delete(Path.Combine(destination, "gone"), recursive: true); + + var preview = _installer.Install(destination, [], dryRun: true, offered: Offer(("Alpha", "2.0.0"))); + var applied = _installer.Install(destination, [], dryRun: false, offered: Offer(("Alpha", "2.0.0"))); + + Assert.Equal(preview.Removed, applied.Removed); + Assert.Equal("gone", Assert.Single(applied.Removed).Skill); + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + } + + [Fact] + public void An_only_adding_install_leaves_every_other_tracked_skill_alone() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + var installedCopy = temp.CreateFile("dest/mockly/local-notes.md", "edited after install"); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Gamma", "1.0.0", "gamma")], + dryRun: false, + offered: Offer()); + + Assert.Equal("gamma", Assert.Single(outcome.Installed).SkillName); + Assert.Empty(outcome.Removed); + Assert.Equal("edited after install", File.ReadAllText(installedCopy)); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + Assert.Equal( + ["contoso.widgets", "gamma", "mockly"], + InstallManifest.Load(destination).Packages.Keys); + } + + [Fact] + public void Install_replaces_files_that_a_newer_package_version_dropped() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var first = Skill(temp, "Mockly", "1.10.0", "mockly"); + File.WriteAllText(Path.Combine(first.SourcePath, "obsolete.md"), "gone in the next version"); + _installer.Install(destination, [first], dryRun: false); + + // Reinstalling the same version from a source that no longer has the file must + // not leave the stale copy behind. + File.Delete(Path.Combine(first.SourcePath, "obsolete.md")); + _installer.Install(destination, [first], dryRun: false); + + Assert.False(File.Exists(Path.Combine(destination, "mockly", "obsolete.md"))); + } + + [Fact] + public void Install_clears_the_read_only_flag_that_restore_puts_on_cached_files() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var skill = Skill(temp, "Mockly", "1.10.0", "mockly"); + + var source = Path.Combine(skill.SourcePath, "SKILL.md"); + File.SetAttributes(source, File.GetAttributes(source) | FileAttributes.ReadOnly); + + try + { + _installer.Install(destination, [skill], dryRun: false); + + var copied = Path.Combine(destination, "mockly", "SKILL.md"); + Assert.False(File.GetAttributes(copied).HasFlag(FileAttributes.ReadOnly)); + + // The real point: a second install must be able to overwrite the copy. + _installer.Install(destination, [skill], dryRun: false); + } + finally + { + File.SetAttributes(source, File.GetAttributes(source) & ~FileAttributes.ReadOnly); + } + } + + [Fact] + public void Uninstall_against_references_removes_only_stale_skills_within_a_chosen_list() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "mockly-usage"), + Skill(temp, "Mockly", "1.10.0", "mockly-setup"), + Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"), + ], + dryRun: false); + PackageReferenceInfo[] references = [new("Contoso.Widgets", "2.3.0"), new("Mockly", "1.11.0")]; + + var removed = _installer.Uninstall( + destination, packageId: null, packageVersion: null, dryRun: false, + only: ["mockly-setup", "widget-usage"], staleAgainst: references); + + // widget-usage was chosen, but the target still references its version. + Assert.Equal("mockly-setup", Assert.Single(removed).Skill); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly-usage"))); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + } + + [Fact] + public void Uninstall_removes_only_the_skills_it_was_given() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "mockly-usage"), + Skill(temp, "Mockly", "1.10.0", "mockly-setup"), + Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"), + ], + dryRun: false); + + // What the interactive picker hands back: an explicit list, not a package filter. + var removed = _installer.Uninstall( + destination, + packageId: null, + packageVersion: null, + dryRun: false, + only: ["mockly-setup", "widget-usage"]); + + Assert.Equal(["mockly-setup", "widget-usage"], removed.Select(entry => entry.Skill)); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly-usage"))); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-setup"))); + Assert.Single(InstallManifest.Load(destination).EnumerateSkills()); + } + + [Fact] + public void Uninstall_given_an_empty_list_removes_nothing() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + // Confirming the picker without ticking anything must not be read as "all of them". + var removed = _installer.Uninstall( + destination, + packageId: null, + packageVersion: null, + dryRun: false, + only: []); + + Assert.Empty(removed); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly"))); + } + + [Fact] + public void Uninstall_combines_a_chosen_list_with_a_package_filter() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "mockly-usage"), + Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"), + ], + dryRun: false); + + var removed = _installer.Uninstall( + destination, + packageId: "Mockly", + packageVersion: null, + dryRun: false, + only: ["mockly-usage", "widget-usage"]); + + // widget-usage was ticked but belongs to another package, so the filter still holds. + Assert.Equal("mockly-usage", Assert.Single(removed).Skill); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + } + + [Fact] + public void Uninstall_removes_everything_it_installed_including_the_manifest() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var removed = _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: false); + + Assert.Single(removed); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Uninstall_can_target_a_single_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + + _installer.Uninstall(destination, packageId: "mockly", packageVersion: null, dryRun: false); + + Assert.False(Directory.Exists(Path.Combine(destination, "mockly"))); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + Assert.True(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + } + + [Fact] + public void Uninstall_with_dryRun_writes_nothing() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var removed = _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: true); + + Assert.Single(removed); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly"))); + } + + [Fact] + public void Uninstall_on_an_untouched_folder_reports_nothing_to_do() + { + using var temp = new TempDirectory(); + + Assert.Empty(_installer.Uninstall(temp.Combine("dest"), packageId: null, packageVersion: null, dryRun: false)); + } + + [Fact] + public void Uninstall_leaves_hand_authored_skills_in_place() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var handAuthored = Path.Combine(destination, "our-own-skill"); + Directory.CreateDirectory(handAuthored); + File.WriteAllText(Path.Combine(handAuthored, "SKILL.md"), "ours"); + + _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: false); + + Assert.True(File.Exists(Path.Combine(handAuthored, "SKILL.md"))); + } + + [Fact] + public void A_corrupt_manifest_blocks_install_and_preserves_everything() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "already-installed")], + dryRun: false); + + var manifest = Path.Combine(destination, InstallManifest.FileName); + const string corrupt = "{ not json"; + File.WriteAllText(manifest, corrupt); + + var error = Assert.Throws(() => + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "not-installed")], + dryRun: false)); + + Assert.Contains(manifest, error.Message); + Assert.Contains("preserved", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(corrupt, File.ReadAllText(manifest)); + Assert.True(File.Exists(Path.Combine(destination, "already-installed", "SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(destination, "not-installed"))); + } + + [Fact] + public void A_corrupt_manifest_blocks_uninstall_and_preserves_everything() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly")], + dryRun: false); + + var manifest = Path.Combine(destination, InstallManifest.FileName); + const string corrupt = """ + <<<<<<< HEAD + {"installed":[]} + ======= + {"installed":[]} + >>>>>>> feature + """; + File.WriteAllText(manifest, corrupt); + + var error = Assert.Throws(() => + _installer.Uninstall( + destination, + packageId: null, + packageVersion: null, + dryRun: false)); + + Assert.Contains(manifest, error.Message); + Assert.Equal(corrupt, File.ReadAllText(manifest)); + Assert.True(File.Exists(Path.Combine(destination, "mockly", "SKILL.md"))); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Unsafe_manifest_aliases_block_all_changes_and_preserve_handwritten_skills(bool uninstall, bool dryRun) + { + foreach (var unsafeName in new[] { "...", "....", ".. ", "... ", "our-own-skill.", "our-own-skill " }) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "already-installed")], + dryRun: false); + temp.CreateFile("dest/our-own-skill/SKILL.md", "handwritten guidance"); + temp.CreateFile("dest/our-own-skill/references/details.md", "handwritten reference"); + var next = Skill(temp, "Mockly", "1.10.0", "not-installed"); + var manifest = Path.Combine(destination, InstallManifest.FileName); + File.WriteAllText(manifest, $$""" + { + "version": 1, + "packages": { + "contoso.widgets": {"version":"2.3.0","skills":["already-installed"]}, + "mockly": {"version":"1.10.0","skills":["{{unsafeName}}"]} + } + } + """); + var before = Snapshot(destination); + + var error = Assert.Throws(() => + { + if (uninstall) + { + _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun); + } + else + { + _installer.Install(destination, [next], dryRun); + } + }); + + Assert.Contains("not a safe skill folder name", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Contains("preserved", error.Message); + Assert.Equal(before, Snapshot(destination)); + Assert.False(Directory.Exists(Path.Combine(destination, "not-installed"))); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Unsafe_candidate_paths_cannot_bypass_manifest_validation_or_prune_existing_skills(bool dryRun) + { + foreach (var unsafeName in new[] { "...", "..", "our-own-skill.", "our-own-skill ", "../outside" }) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Old", "1.0.0", "old")], dryRun: false); + temp.CreateFile("dest/our-own-skill/SKILL.md", "handwritten guidance"); + temp.CreateFile("outside/SKILL.md", "outside the destination"); + var next = Skill(temp, "Alpha", "1.0.0", "next"); + var unsafeSkill = Skill(temp, "Beta", "1.0.0", "source") with + { + SkillName = unsafeName, + RelativePath = unsafeName, + }; + var before = Snapshot(temp.Path); + + var error = Assert.Throws(() => + _installer.Install(destination, [next, unsafeSkill], dryRun)); + + Assert.Contains("safe skill folder", error.Message); + Assert.Equal(before, Snapshot(temp.Path)); + } + } + + [Theory] + [InlineData(".hidden-skill")] + [InlineData("...usage")] + [InlineData("skill name")] + public void Safe_names_with_dots_or_spaces_remain_installable_and_removable(string skillName) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest") + Path.DirectorySeparatorChar; + var handwritten = temp.CreateFile("dest/our-own-skill/SKILL.md", "ours"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", skillName)], dryRun: false); + Assert.True(File.Exists(Path.Combine(destination, skillName, "SKILL.md"))); + + var removed = _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: false); + + Assert.Equal(skillName, Assert.Single(removed).Skill); + Assert.False(Directory.Exists(Path.Combine(destination, skillName))); + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + Assert.Equal("ours", File.ReadAllText(handwritten)); + } + + private static (string Path, string Contents)[] Snapshot(string root) => + Directory.Exists(root) + ? + [ + .. Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => (Path.GetRelativePath(root, path), Convert.ToHexString(File.ReadAllBytes(path)))), + ] + : []; + + private static Dictionary Offer(params (string Id, string Version)[] packages) => + packages.ToDictionary(package => package.Id, package => package.Version, StringComparer.OrdinalIgnoreCase); +} diff --git a/dotnet-package-skills/tests/SkillPickerTests.cs b/dotnet-package-skills/tests/SkillPickerTests.cs new file mode 100644 index 0000000..3c59aa2 --- /dev/null +++ b/dotnet-package-skills/tests/SkillPickerTests.cs @@ -0,0 +1,2194 @@ +using System.Text; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillPickerTests +{ + private const string Title = "Skills for App.slnx"; + + [Theory] + [InlineData(0, true, false)] + [InlineData(8, true, false)] + [InlineData(58, true, false)] + [InlineData(8, false, false)] + [InlineData(0, true, true)] + [InlineData(8, true, true)] + [InlineData(58, true, true)] + [InlineData(8, false, true)] + public void The_first_frame_starts_at_the_top_regardless_of_the_shell_cursor( + int cursorRow, bool color, bool uninstall) + { + var terminal = new FakeTerminal(windowHeight: 70, windowWidth: 140) + { + SupportsColor = color, + }.Press(ConsoleKey.Escape); + terminal.WriteLine("previous shell output"); + terminal.SetCursorPosition(12, cursorRow); + var originalScreen = terminal.Screen; + var originalState = terminal.CaptureState(); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ClearViewport)) + { + Assert.True(terminal.IsInteractiveScreen); + } + }; + + new SkillPicker(terminal).Choose(Items(3), Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.StartsWith(Title, Assert.Single(terminal.Frames)); + Assert.Equal(0, Assert.Single(terminal.Writes, write => write.Text == Title).Top); + Assert.Equal(originalScreen, terminal.Screen); + Assert.Equal(cursorRow, terminal.FinalCursorTop); + Assert.Equal(originalState, terminal.CaptureState()); + Assert.Equal(1, terminal.ViewportClears); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void Picker_frames_are_isolated_from_prior_shell_output_and_removed_on_exit(ConsoleKey exit) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, exit); + terminal.WriteLine("earlier shell output"); + var before = terminal.Screen; + var cursor = terminal.CursorTop; + var state = terminal.CaptureState(); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey)) + { + Assert.True(terminal.IsInteractiveScreen); + Assert.DoesNotContain("earlier shell output", terminal.Screen); + } + }; + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(before, terminal.Screen); + Assert.Equal(cursor, terminal.CursorTop); + Assert.Equal(state, terminal.CaptureState()); + Assert.Contains(Title, terminal.LastPickerScreen); + Assert.DoesNotContain(Title, terminal.Screen); + Assert.Equal(1, terminal.ScreenEntries); + Assert.Equal(1, terminal.ScreenExits); + Assert.False(terminal.IsInteractiveScreen); + } + + [Fact] + public void Ctrl_C_restores_the_normal_screen_without_retaining_a_picker_copy() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A).PressWith(ConsoleModifiers.Control, ConsoleKey.C); + terminal.WriteLine("prior output"); + var before = terminal.Screen; + + Assert.Null(new SkillPicker(terminal).Choose(Items(24), Title)); + + Assert.Equal(before, terminal.Screen); + Assert.False(terminal.IsInteractiveScreen); + Assert.Equal(1, terminal.ScreenExits); + } + + [Fact] + public void Rendering_failure_still_restores_the_original_screen() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + terminal.WriteLine("prior output"); + var before = terminal.Screen; + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.Write) && terminal.CurrentStyle == TerminalStyle.Selected) + { + throw new IOException("failed during selection render"); + } + }; + + Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal(before, terminal.Screen); + Assert.False(terminal.IsInteractiveScreen); + Assert.Equal(1, terminal.ScreenExits); + } + + [Fact] + public void A_note_under_the_title_is_shown_on_every_page_and_fits_the_frame() + { + const string Note = "Installed skills aren't listed."; + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.PageDown, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title, PickerMode.Install, Note); + + Assert.All(terminal.Frames, frame => + { + var lines = frame.Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + Assert.StartsWith(Title, lines[0]); + Assert.Equal(Note, lines[1]); + Assert.Equal(string.Empty, lines[2]); + }); + Assert.Contains("page 2 of", terminal.Frames[1]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Picker_shows_one_page_of_skills_at_a_time() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("skill-01", frame); + Assert.Contains("skill-08", frame); + Assert.DoesNotContain("skill-09", frame); + Assert.Contains("page 1 of 3", frame); + } + + [Fact] + public void An_install_list_starts_with_nothing_ticked() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(3), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("[ ] skill-01", frame); + Assert.Contains("[ ] skill-02", frame); + Assert.Contains("[ ] skill-03", frame); + Assert.DoesNotContain("[X]", frame); + Assert.DoesNotContain("installed", frame); + Assert.Empty(choice!); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 0, 1).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 0, 2).Style); + } + + [Fact] + public void Neutral_rows_show_the_description_instead_of_a_status_column() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(4), Title); + + var rows = Rows(terminal.Frames[0]); + + Assert.Equal("[ ] skill-01 - No description provided.", rows[0]); + Assert.Equal("[ ] skill-02 - No description provided.", rows[1]); + } + + [Fact] + public void Ticking_a_new_skill_colors_only_its_X_blue_when_it_is_not_focused() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 0, 1).Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 2, 1, "X").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "[").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "]").Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 2, 1).Style); + Assert.Equal("[X] skill-01 - No description provided.", Rows(terminal.Frames[1])[0]); + Assert.DoesNotContain("will install", terminal.Frames[1]); + } + + [Fact] + public void An_install_list_never_mentions_removal() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.A, ConsoleKey.C, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.All(terminal.Frames, frame => + Assert.DoesNotContain("remove", frame, StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Both_pickers_draw_a_tick_the_same_way(bool uninstall) + { + // Each checklist does one thing, so a tick needs no second cue for what it does; the + // title and the summary say that. Only the X is blue, and the brackets follow the row. + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 1, 1, "[").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 1, 1, "]").Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 2, 1, "X").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "[").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "]").Style); + Assert.All(terminal.Frames, frame => + { + Assert.Contains("Blue X: selected", frame); + Assert.DoesNotContain("brackets", frame, StringComparison.OrdinalIgnoreCase); + }); + } + + [Fact] + public void Package_attribution_does_not_appear_after_the_authored_skill_name() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Contains("skill-01 - No description provided.", terminal.Frames[0]); + Assert.DoesNotContain("Package.1", terminal.Frames[0]); + Assert.DoesNotContain("1.0.0", terminal.Frames[0]); + } + + /// The skill rows of a frame, trimmed of the cursor column and padding. + private static List Rows(string frame) => + [ + .. frame.Split(Environment.NewLine) + .Where(line => line.Contains('[', StringComparison.Ordinal)) + .Select(line => line[line.IndexOf('[')..].TrimEnd()), + ]; + + private static TerminalWrite SkillSpan(FakeTerminal terminal, int frame, int skill) => + Assert.Single(terminal.FrameWrites[frame], write => + write.Text == $" skill-{skill:00}"); + + private static TerminalWrite CheckboxSpan(FakeTerminal terminal, int frame, int skill, string part) + { + var row = SkillSpan(terminal, frame, skill).Top; + return Assert.Single(terminal.FrameWrites[frame], write => write.Top == row && write.Text == part); + } + + [Fact] + public void Uninstalling_starts_with_nothing_ticked() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + // Every row is installed, and a tick deletes. Pre-ticking them would make a mistaken + // enter wipe the lot. + var choice = new SkillPicker(terminal) + .Choose(Items(5), Title, PickerMode.Uninstall); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void Uninstalling_a_ticked_row_says_how_many_skills_will_be_removed() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal) + .Choose(Items(3), Title, PickerMode.Uninstall); + + Assert.Equal("skill-01", Assert.Single(choice!)); + Assert.Equal("[X] skill-01 - No description provided.", Rows(terminal.Frames[1])[0]); + Assert.Contains("1 of 3 selected; 1 to remove", terminal.Frames[1]); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 1, 1).Style); + } + + [Fact] + public void Uninstalling_says_nothing_about_rows_left_alone() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall); + + var frame = terminal.Frames[0]; + Assert.DoesNotContain("installed", frame); + Assert.DoesNotContain("will install", frame); + Assert.Equal("[ ] skill-01 - No description provided.", Rows(frame)[0]); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 0, 1).Style); + } + + [Fact] + public void Uninstalling_counts_selected_removals_without_an_install_count() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(4), Title, PickerMode.Uninstall); + + Assert.Contains("0 of 4 selected; 0 to remove", terminal.Frames[0]); + Assert.Contains("1 of 4 selected; 1 to remove", terminal.Frames[1]); + Assert.DoesNotContain("to install", terminal.Frames[1]); + } + + [Fact] + public void Uninstalling_can_take_everything_with_one_key() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal) + .Choose(Items(24), Title, PickerMode.Uninstall); + + Assert.Equal(24, choice!.Count); + } + + [Fact] + public void Uninstalling_cancelled_removes_nothing() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Escape); + + Assert.Null(new SkillPicker(terminal) + .Choose(Items(5), Title, PickerMode.Uninstall)); + } + + [Fact] + public void Uninstalling_without_a_terminal_says_what_to_do_instead() + { + var terminal = new FakeTerminal { IsRedirected = true }; + + var error = Assert.Throws( + () => new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall)); + + // The install wording tells you to drop the flag and install everything, which is the + // opposite of what this command would then do. It also can't point at --package, which + // uninstall --stale refuses. + Assert.Contains("remove every skill that the command matches", error.Message); + Assert.DoesNotContain("--package", error.Message); + } + + [Fact] + public void Pressing_enter_immediately_installs_nothing() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(5), Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void Moving_past_the_last_item_on_a_page_shows_the_next_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.DownArrow, times: 8).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[8]; + Assert.Contains("page 2 of 3", frame); + Assert.Contains("> [ ] skill-09", frame); + Assert.DoesNotContain("skill-08", frame); + } + + [Fact] + public void Moving_up_from_the_first_skill_wraps_to_the_last_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.UpArrow).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[1]; + Assert.Contains("page 3 of 3", frame); + Assert.Contains("> [ ] skill-24", frame); + } + + [Fact] + public void Right_arrow_pages_forward_without_moving_within_the_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("> [ ] skill-09", terminal.Frames[1]); + } + + [Fact] + public void Moving_down_from_the_last_skill_wraps_to_the_first_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.End, ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[2]; + Assert.Contains("page 1 of 3", frame); + Assert.Contains("> [ ] skill-01", frame); + } + + [Fact] + public void Left_arrow_pages_back() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.RightArrow, ConsoleKey.RightArrow, ConsoleKey.LeftArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("page 3 of 3", terminal.Frames[2]); + + var frame = terminal.Frames[3]; + Assert.Contains("page 2 of 3", frame); + Assert.Contains("> [ ] skill-09", frame); + } + + [Fact] + public void Page_up_and_page_down_page_like_the_arrows() + { + var terminal = new FakeTerminal().Press(ConsoleKey.PageDown, ConsoleKey.PageUp, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("page 2 of 3", terminal.Frames[1]); + Assert.Contains("page 1 of 3", terminal.Frames[2]); + } + + [Fact] + public void Home_and_end_jump_to_the_first_and_last_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.End, ConsoleKey.Home, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("> [ ] skill-24", terminal.Frames[1]); + Assert.Contains("> [ ] skill-01", terminal.Frames[2]); + } + + [Fact] + public void Space_selects_the_focused_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.NotNull(choice); + Assert.Equal("skill-01", Assert.Single(choice)); + } + + [Fact] + public void Pressing_space_again_unticks_the_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void A_selects_every_skill_on_every_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.NotNull(choice); + Assert.Equal(24, choice.Count); + } + + [Fact] + public void C_clears_every_skill_on_every_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.C, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void The_install_summary_counts_selections_only() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(5), Title); + + Assert.Contains("0 of 5 selected", terminal.Frames[0]); + Assert.Contains("1 of 5 selected", terminal.Frames[1]); + Assert.DoesNotContain("to install", terminal.Frames[1]); + Assert.DoesNotContain("to remove", terminal.Frames[1]); + } + + [Fact] + public void Ctrl_c_cancels_without_choosing_anything() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A).PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + Assert.Null(new SkillPicker(terminal).Choose(Items(24), Title)); + } + + [Fact] + public void Ctrl_c_is_not_mistaken_for_the_clear_all_key() + { + var terminal = new FakeTerminal().PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + // A bare 'c' clears the selection and keeps going, so the modifier has to win. + Assert.Null(new SkillPicker(terminal).Choose(Items(5), Title)); + } + + [Fact] + public void Ctrl_c_restores_the_terminal_on_the_way_out() + { + var terminal = new FakeTerminal().PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + } + + [Fact] + public void Ctrl_c_is_taken_as_a_key_rather_than_killing_the_process() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Escape); + + new SkillPicker(terminal).Choose(Items(3), Title); + + // Left to the runtime, Ctrl+C ends the process mid-frame and the cursor is never + // put back. Capturing it is what makes the restore reachable at all. + Assert.True(terminal.ControlCWasEverTakenAsInput); + Assert.False(terminal.IsControlCTakenAsInput); + } + + [Fact] + public void Cancelling_on_a_partial_page_parks_the_picker_then_restores_the_shell_cursor() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow); + terminal.PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + new SkillPicker(terminal).Choose(Items(12), Title); + + // Four complete entries, nine measured chrome rows, and the closing blank line. + Assert.Equal(14, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + } + + [Fact] + public void A_partial_page_parks_the_cursor_under_the_footer_while_it_waits() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + // Where the cursor rests between keys is where a prompt lands if the process is + // killed outright, which is what Ctrl+C does on a host that will not hand it over. + // Leaving it at the bottom of the reserved rows is the whitespace bug itself. + Assert.Equal(13, terminal.CursorTopAwaitingKey); + } + + [Fact] + public void A_full_page_parks_the_cursor_under_the_footer_while_it_waits() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + Assert.Equal(17, terminal.CursorTopAwaitingKey); + } + + [Fact] + public void Cancelling_on_a_full_page_restores_the_original_screen_cursor() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Escape); + + new SkillPicker(terminal).Choose(Items(12), Title); + + Assert.Equal(17, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + } + + [Fact] + public void Escape_cancels_without_choosing_anything() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Escape); + + Assert.Null(new SkillPicker(terminal).Choose(Items(3), Title)); + } + + [Fact] + public void Q_cancels_without_choosing_anything() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Q); + + Assert.Null(new SkillPicker(terminal).Choose(Items(3), Title)); + } + + [Fact] + public void A_tall_window_shows_more_skills_per_page() + { + var terminal = new FakeTerminal(windowHeight: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // The entire list and its measured, non-paging footer fit in this viewport. + var frame = terminal.Frames[0]; + Assert.Contains("skill-24", frame); + Assert.DoesNotContain("page 1 of", frame); + } + + [Fact] + public void A_window_taller_than_the_list_does_not_page_at_all() + { + var terminal = new FakeTerminal(windowHeight: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // Nothing to page to, so the paging key is dropped along with the counter. + Assert.DoesNotContain("change page", terminal.Frames[0]); + } + + [Fact] + public void The_page_is_the_window_height_not_a_fixed_ceiling() + { + // Eighteen entries fit beside the measured footer. A fixed ten-row cap would hide + // eight entries that fit, even before descriptions change their rendered heights. + var terminal = new FakeTerminal(windowHeight: 28).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(60), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("skill-18", frame); + Assert.DoesNotContain("skill-19", frame); + Assert.Contains("page 1 of 4", frame); + } + + [Fact] + public void A_short_window_shrinks_the_page_rather_than_overflowing_it() + { + var terminal = new FakeTerminal(windowHeight: 12).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("skill-02", frame); + Assert.DoesNotContain("skill-03", frame); + Assert.Contains("page 1 of 12", frame); + } + + [Fact] + public void A_window_too_short_for_essential_controls_gives_actionable_guidance() + { + var terminal = new FakeTerminal(windowHeight: 4).Press(ConsoleKey.Enter); + + var error = Assert.Throws( + () => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains("too small", error.Message); + Assert.Contains("Enlarge the window", error.Message); + Assert.Contains("--package", error.Message); + Assert.Empty(terminal.Frames); + Assert.Empty(terminal.Writes); + } + + [Fact] + public void A_partial_last_page_puts_the_summary_under_its_final_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + var lines = terminal.Frames[1].Split(Environment.NewLine); + var lastSkill = Array.FindIndex(lines, line => line.Contains("skill-12", StringComparison.Ordinal)); + var summary = Array.FindIndex(lines, line => line.Contains("of 12 selected", StringComparison.Ordinal)); + + Assert.True(lastSkill > 0, "the last skill should be on the page"); + // A blank separator, not padding out to a full page. + Assert.Equal(lastSkill + 2, summary); + } + + [Fact] + public void Paging_to_a_shorter_page_erases_what_the_taller_one_left_behind() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + // In-place redrawing can only erase by overwriting, so page one's rows have to be + // blanked rather than simply skipped. + var frame = terminal.Frames[1]; + Assert.DoesNotContain("skill-01", frame); + Assert.DoesNotContain("skill-08", frame); + Assert.Contains("skill-09", frame); + Assert.Contains("skill-12", frame); + } + + [Fact] + public void Paging_back_to_a_full_page_redraws_every_row() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.LeftArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + var frame = terminal.Frames[2]; + Assert.Contains("skill-01", frame); + Assert.Contains("skill-08", frame); + Assert.DoesNotContain("skill-09", frame); + } + + [Fact] + public void A_full_last_page_is_unchanged_by_the_partial_page_handling() + { + var terminal = new FakeTerminal().Press(ConsoleKey.End, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(16), Title); + + var lines = terminal.Frames[1].Split(Environment.NewLine); + var lastSkill = Array.FindIndex(lines, line => line.Contains("skill-16", StringComparison.Ordinal)); + var summary = Array.FindIndex(lines, line => line.Contains("of 16 selected", StringComparison.Ordinal)); + + Assert.Equal(lastSkill + 2, summary); + Assert.Contains("page 2 of 2", terminal.Frames[1]); + } + + [Fact] + public void The_frame_is_only_as_wide_as_its_content_on_a_wide_terminal() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + // Padding every row out to the window would strand the page counter at the far edge + // and trail whitespace far past the text it belongs to. + Assert.All( + terminal.Frames[0].Split(Environment.NewLine), + line => Assert.True(line.Length < 80, $"line is {line.Length} columns wide: '{line}'")); + } + + [Fact] + public void The_page_counter_sits_beside_the_title_not_at_the_far_edge() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var header = terminal.Frames[0].Split(Environment.NewLine)[0]; + Assert.EndsWith("page 1 of 3", header.TrimEnd()); + Assert.True(header.TrimEnd().Length < 80, $"header is {header.TrimEnd().Length} columns wide"); + } + + [Fact] + public void A_long_skill_name_still_widens_the_frame_to_fit() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + var name = new string('x', 40); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(name, "Some.Package", "1.0.0"), + ], + Title); + + Assert.Contains(name, terminal.Frames[0]); + } + + [Fact] + public void A_name_far_longer_than_any_constant_survives_on_a_wide_terminal() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + + // The column used to stop at a hardcoded 44, so this lost its tail with most of the + // window still empty beside it. The terminal is the only thing that gets to decide. + var name = "contoso.widgets-extremely-long-skill-name-that-keeps-going-and-going"; + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(name, "Contoso.Widgets", "2.3.0"), + ], + Title); + + Assert.Contains(name, terminal.Frames[0]); + Assert.DoesNotContain("...", terminal.Frames[0]); + } + + [Fact] + public void A_narrow_terminal_truncates_the_name_rather_than_overflowing_the_row() + { + var terminal = new FakeTerminal(windowWidth: 50).Press(ConsoleKey.Enter); + var name = "contoso.widgets-extremely-long-skill-name-that-keeps-going"; + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(name, "Contoso.Widgets", "2.3.0"), + ], + Title); + + var frame = terminal.Frames[0]; + Assert.Contains("...", frame); + Assert.All( + frame.Split(Environment.NewLine), + line => Assert.True(line.Length < 50, $"'{line}' is {line.Length} columns wide")); + } + + [Fact] + public void The_name_column_grows_with_the_terminal() + { + const string Name = "contoso.widgets-a-name-of-some-considerable-length-indeed"; + + static int DescriptionColumnAt(int windowWidth) + { + var terminal = new FakeTerminal(windowWidth: windowWidth).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(Name, "Contoso.Widgets", "2.3.0"), + ], + Title); + + return terminal.Frames[0] + .Split(Environment.NewLine) + .Single(line => line.StartsWith("> [", StringComparison.Ordinal)) + .IndexOf(" - ", StringComparison.Ordinal); + } + + Assert.True(DescriptionColumnAt(140) > DescriptionColumnAt(70), "a wider terminal should give the name more room"); + } + + [Fact] + public void Rows_are_padded_so_a_shorter_frame_cannot_leave_the_previous_one_behind() + { + // Changing counts or focus must not leave an earlier row showing through. + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + var lengths = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.Length).Distinct(); + Assert.Single(lengths); + } + + [Fact] + public void A_single_skill_does_not_leave_a_page_of_blank_rows_behind_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(1), Title); + + // A single entry and its useful footer, not a page's worth of reserved skill rows. + Assert.Equal(8, terminal.Frames[0].Split(Environment.NewLine).Length); + } + + [Fact] + public void A_short_list_shrinks_the_frame_to_fit_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(11, terminal.Frames[0].Split(Environment.NewLine).Length); + } + + [Fact] + public void A_list_longer_than_a_page_still_fills_the_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Equal(17, terminal.Frames[0].Split(Environment.NewLine).Length); + } + + [Fact] + public void One_page_of_skills_shows_no_page_counter() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(4), Title); + + var frame = terminal.Frames[0]; + Assert.Contains(Title, frame); + Assert.DoesNotContain("page 1 of 1", frame); + // Nothing to page to, so offering the key would teach a control that does nothing. + Assert.DoesNotContain("change page", frame); + Assert.Contains("/ to move", frame); + } + + [Fact] + public void A_single_skill_offers_neither_paging_nor_movement() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(1), Title); + + var frame = terminal.Frames[0]; + Assert.DoesNotContain("/", frame); + Assert.DoesNotContain("change page", frame); + // With one skill, select-all and clear-all are a slower way to press space. + Assert.DoesNotContain("select all", frame); + Assert.DoesNotContain("clear all", frame); + Assert.Contains(PickerLayout.PrimaryHelp, frame); + Assert.Contains("(Press // to cancel)", frame); + } + + [Fact] + public void The_bottom_help_uses_Aspire_key_syntax_and_subdued_spans() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("(Press to select, to accept)", frame); + Assert.Contains("(Press / to move", frame); + Assert.Contains("(Press /, / to change page)", frame); + Assert.Contains("/ for first/last", frame); + Assert.Contains("(Press to select all", frame); + Assert.Contains(" to clear all", frame); + Assert.Contains("// to cancel", frame); + Assert.All( + terminal.FrameWrites[0].Where(write => write.Text.StartsWith("(", StringComparison.Ordinal)), + write => Assert.StartsWith("(Press <", write.Text)); + var help = Assert.Single(terminal.FrameWrites[0], write => write.Text == PickerLayout.PrimaryHelp); + Assert.Equal(TerminalStyle.Muted, help.Style); + Assert.True(help.Top > SkillSpan(terminal, 0, 8).Top); + } + + [Fact] + public void More_than_one_page_still_shows_the_counter_and_the_paging_key() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("page 1 of 3", frame); + Assert.Contains("(Press /, / to change page)", frame); + } + + [Fact] + public void A_single_skill_can_still_be_toggled_and_confirmed() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(1), Title); + + Assert.NotNull(choice); + Assert.Equal("skill-01", Assert.Single(choice)); + } + + [Fact] + public void Moving_within_a_single_page_never_leaves_it() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.DownArrow, ConsoleKey.DownArrow, ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.All( + terminal.Frames, + frame => Assert.Equal(11, frame.Split(Environment.NewLine).Length)); + Assert.Contains("> [ ] skill-03", terminal.Frames[^1]); + } + + [Fact] + public void The_frame_never_grows_beyond_the_rows_it_reserved() + { + var terminal = new FakeTerminal().Press(ConsoleKey.DownArrow, times: 30).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // Eight entry rows and nine measured chrome rows, however far the cursor travels. + Assert.All(terminal.Frames, frame => Assert.Equal(17, frame.Split(Environment.NewLine).Length)); + } + + [Fact] + public void The_frame_stays_ascii_so_a_legacy_console_code_page_renders_all_of_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // Windows consoles default to an OEM code page that silently drops arrows and box + // glyphs, so a legend built from them reads as gaps on the most common terminal. + Assert.All( + terminal.Frames[0].Replace(Environment.NewLine, string.Empty), + character => Assert.InRange(character, ' ', '~')); + } + + [Fact] + public void Long_skill_names_are_truncated_rather_than_wrapped() + { + var terminal = new FakeTerminal(windowWidth: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(2), Title); + + Assert.All( + terminal.Frames[0].Split(Environment.NewLine), + line => Assert.True(line.Length < 40, $"'{line}' is {line.Length} characters wide")); + } + + [Fact] + public void The_cursor_is_put_back_when_the_picker_leaves() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Escape); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.True(terminal.IsCursorVisible); + } + + [Fact] + public void A_redirected_terminal_is_refused_with_guidance() + { + var terminal = new FakeTerminal { IsRedirected = true }; + + var error = Assert.Throws( + () => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains("--interactive needs a terminal", error.Message); + Assert.Contains("--package", error.Message); + } + + [Fact] + public void Nothing_to_choose_between_never_prompts() + { + var terminal = new FakeTerminal { IsRedirected = true }; + + var choice = new SkillPicker(terminal).Choose([], Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + Assert.Empty(terminal.Frames); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Both_modes_put_the_description_immediately_after_the_skill_name(bool uninstall) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80).Press(ConsoleKey.Enter); + var mode = uninstall ? PickerMode.Uninstall : PickerMode.Install; + + new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1.2.3", "Short description.")], Title, mode); + + var row = Assert.Single(Rows(terminal.Frames[0])); + Assert.Equal("[ ] alpha - Short description.", row); + var description = Assert.Single(terminal.FrameWrites[0], write => write.Text == " - Short description."); + Assert.Equal(11, description.Left); + Assert.Equal(TerminalStyle.Focus, description.Style); + Assert.Equal(1, terminal.Frames[0].Split("Short description.", StringSplitOptions.None).Length - 1); + Assert.DoesNotContain("will install", row); + Assert.DoesNotContain("will remove", row); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \t\r\n ")] + [InlineData("\x1b[31m\x1b[0m\u202e")] + [InlineData("\u200d")] + public void Missing_or_invisible_description_text_has_an_explicit_fallback(string? description) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", description)], Title); + + Assert.Equal("alpha", Assert.Single(chosen!)); + Assert.Contains(" - No description provided.", terminal.Frames[0]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Description_warnings_are_visible_and_do_not_change_eligibility(bool uninstall) + { + var terminal = new FakeTerminal(windowWidth: 160).Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var mode = uninstall ? PickerMode.Uninstall : PickerMode.Install; + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", "stale text", "cannot read SKILL.md.")], + Title, mode); + + Assert.Equal("alpha", Assert.Single(chosen!)); + Assert.Contains(" - Description unavailable: cannot read SKILL.md.", terminal.Frames[0]); + Assert.DoesNotContain("stale text", terminal.Frames[0]); + Assert.DoesNotContain("No description provided.", terminal.Frames[0]); + } + + [Fact] + public void Descriptions_continue_at_the_skill_text_edge_without_a_name_sized_gap() + { + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1.2.3", + "One two three four five six seven eight nine ten.")], Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + Assert.Equal("> [ ] alpha - One two three four five six", lines[2]); + Assert.Equal(new string(' ', 6) + "seven eight nine ten.", lines[3]); + Assert.Equal(string.Empty, lines[4]); + Assert.Equal("0 of 1 selected", lines[5]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Descriptions_follow_differently_sized_names_without_padding() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem("alpha", "Pkg", "1.2.3", "First."), + new SkillPickerItem("beta", "Pkg", "1.2.3", "Second."), + ], + Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine); + Assert.Equal("> [ ] alpha - First.", lines[2].TrimEnd()); + Assert.Equal(" [ ] beta - Second.", lines[3].TrimEnd()); + var descriptions = terminal.FrameWrites[0].Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal)).ToArray(); + Assert.Equal([11, 10], descriptions.Select(write => write.Left)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Picker_names_keep_the_package_prefix_but_omit_the_metadata_suffix(bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 100) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + const string name = "contoso.widgets-batching"; + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem(name, "Contoso.Widgets", "2.3.0", "Batching widget calls.")], + Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal(name, Assert.Single(chosen!)); + Assert.All(terminal.Frames, frame => + { + Assert.Contains($"{name} - Batching widget calls.", frame); + Assert.DoesNotContain("(Contoso.Widgets 2.3.0)", frame); + }); + AssertWithinWindow(terminal); + } + + [Fact] + public void Help_wraps_instead_of_truncating_the_Aspire_prompt() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(1), Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + var first = Array.FindIndex(lines, line => line.StartsWith("(Press ", StringComparison.Ordinal)); + Assert.Equal("(Press to select, to", lines[first]); + Assert.Equal("accept)", lines[first + 1]); + Assert.Equal("(Press to select, to accept)", $"{lines[first]} {lines[first + 1]}"); + Assert.Contains("(Press // to cancel)", string.Join(" ", lines)); + Assert.All(terminal.FrameWrites[0].Where(write => write.Top == first || write.Top == first + 1), + write => Assert.True(string.IsNullOrWhiteSpace(write.Text) || write.Style == TerminalStyle.Muted)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Ticked_additions_are_counted_and_only_their_X_is_colored() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(["skill-01", "skill-02"], chosen!.Order(StringComparer.Ordinal)); + Assert.Contains("2 of 3 selected", terminal.Frames[^1]); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 3, 1).Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 3, 1, "X").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 3, 1, "[").Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 3, 2, "X").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 3, 2, "[").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 3, 2).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 3, 3).Style); + } + + [Fact] + public void Blue_focus_covers_the_whole_row_and_a_tick_away_from_focus_keeps_only_its_blue_x() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Spacebar, ConsoleKey.UpArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall); + + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 1, 1).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 2, 1).Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 3, 2, "[").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 4, 1).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 4, 2).Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 4, 2, "]").Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 4, 2, "X").Style); + Assert.All(terminal.FrameWrites, writes => + { + var focus = Assert.Single(writes, write => write.Text == ">"); + Assert.Equal(TerminalStyle.Focus, focus.Style); + Assert.Equal(0, focus.Left); + Assert.All(writes.Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal)), + write => Assert.Equal(write.Top == focus.Top ? TerminalStyle.Focus : TerminalStyle.Default, write.Style)); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Focus_colors_every_wrapped_line_and_clears_from_the_previous_skill(bool uninstall) + { + var terminal = new FakeTerminal(windowHeight: 30, windowWidth: 80) + .Press(ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem("alpha", "Pkg", "1", "First line.\nSecond line.\nThird line."), + new SkillPickerItem("beta", "Pkg", "1", "Another line.\nAnother continuation."), + ], + Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + var alpha = Assert.Single(terminal.FrameWrites[0], write => write.Text == " alpha").Top; + var beta = Assert.Single(terminal.FrameWrites[0], write => write.Text == " beta").Top; + Assert.Equal(3, beta - alpha); + for (var frame = 0; frame < 2; frame++) + { + Assert.All( + terminal.FrameWrites[frame].Where(write => write.Top >= alpha && write.Top < beta && + !string.IsNullOrWhiteSpace(write.Text)), + write => Assert.Equal(frame == 0 ? TerminalStyle.Focus : TerminalStyle.Default, write.Style)); + Assert.All( + terminal.FrameWrites[frame].Where(write => write.Top >= beta && write.Top < beta + 2 && + !string.IsNullOrWhiteSpace(write.Text)), + write => Assert.Equal(frame == 1 ? TerminalStyle.Focus : TerminalStyle.Default, write.Style)); + } + + AssertWithinWindow(terminal); + } + + [Fact] + public void Unticking_a_new_installation_returns_it_to_neutral() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.DoesNotContain(terminal.FrameWrites[2], write => write.Text == "X"); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 2, 1).Style); + Assert.Contains("0 of 3 selected", terminal.Frames[2]); + } + + [Fact] + public void Unticking_an_uninstall_row_returns_it_to_neutral() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall); + + Assert.Empty(chosen!); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 2, 1, "[").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 2, 1).Style); + Assert.Contains("0 of 3 selected; 0 to remove", terminal.Frames[2]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Without_color_both_pickers_show_a_tick_with_the_checkbox_alone(bool uninstall) + { + // A tick means one thing throughout a checklist, so nothing is needed beside the + // checkbox, and there is no color left for a legend to explain. + var terminal = new FakeTerminal { SupportsColor = false }; + terminal.Press(ConsoleKey.A, ConsoleKey.C, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal) + .Choose(Items(3), Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains("> [ ] skill-01", terminal.Frames[0]); + Assert.Contains("> [X] skill-01", terminal.Frames[1]); + Assert.Contains(" [X] skill-02", terminal.Frames[1]); + Assert.Contains(" [ ] skill-02", terminal.Frames[2]); + Assert.Contains("> [X] skill-01", terminal.Frames[3]); + Assert.Contains(" [ ] skill-02", terminal.Frames[3]); + Assert.All(terminal.StyleEvents, style => Assert.Equal(TerminalStyle.Default, style)); + Assert.All(terminal.Frames, frame => + { + Assert.DoesNotContain("+ [", frame); + Assert.DoesNotContain("- [", frame); + Assert.DoesNotContain("+ install", frame); + Assert.DoesNotContain("- remove", frame); + Assert.DoesNotContain("Blue X", frame); + }); + } + + [Theory] + [InlineData(false, null, null, true, true)] + [InlineData(true, null, "xterm-256color", true, false)] + [InlineData(false, "", "xterm-256color", true, false)] + [InlineData(false, "1", "xterm-256color", false, false)] + [InlineData(false, null, "dumb", true, false)] + [InlineData(false, null, "DuMb", false, false)] + [InlineData(false, null, "vt100", true, false)] + [InlineData(false, null, "vt220", false, false)] + [InlineData(false, null, "unknown", false, false)] + [InlineData(false, null, null, false, false)] + [InlineData(false, null, "xterm-256color", false, true)] + [InlineData(false, null, "screen", false, true)] + [InlineData(false, null, "tmux-256color", false, true)] + [InlineData(false, null, "linux", false, true)] + public void System_terminal_respects_NO_COLOR_redirection_and_color_capabilities( + bool redirected, string? noColor, string? term, bool windows, bool expected) + { + Assert.Equal(expected, SystemTerminal.CanUseColor(redirected, noColor, term, windows)); + } + + [Fact] + public void Mixed_description_pages_preserve_whole_entries_and_the_preferred_page_offset() + { + var items = MixedItems(1, 3, 2, 4, 1, 2, 3, 1, 5, 2, 1, 4, 2, 3); + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .Press(ConsoleKey.DownArrow, times: 3) + .Press(ConsoleKey.Spacebar, ConsoleKey.RightArrow, ConsoleKey.Spacebar, + ConsoleKey.RightArrow, ConsoleKey.LeftArrow, ConsoleKey.PageUp, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(items, Title); + + Assert.Equal(["skill-04", "skill-10"], chosen!); + Assert.Equal(Enumerable.Range(1, 6).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[0])); + Assert.Equal(Enumerable.Range(7, 5).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[5])); + Assert.Equal(Enumerable.Range(12, 3).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[7])); + Assert.Contains("> [ ] skill-10", terminal.Frames[5]); + Assert.Contains("> [ ] skill-14", terminal.Frames[7]); + Assert.Contains("> [X] skill-10", terminal.Frames[8]); + Assert.Contains("> [X] skill-04", terminal.Frames[9]); + Assert.Contains("desc-06-2", terminal.Frames[0]); + Assert.DoesNotContain("desc-07-1", terminal.Frames[0]); + Assert.Contains("desc-09-5", terminal.Frames[5]); + Assert.Contains("desc-11-1", terminal.Frames[5]); + Assert.Contains("desc-12-4", terminal.Frames[7]); + Assert.Contains("desc-14-3", terminal.Frames[7]); + Assert.DoesNotContain("desc-11-1", terminal.Frames[7]); + Assert.Equal(18, terminal.CursorTopsAwaitingKey[7]); + Assert.All(terminal.Frames[7].Split(Environment.NewLine).Skip(18), + line => Assert.True(string.IsNullOrWhiteSpace(line))); + AssertWithinWindow(terminal); + } + + [Fact] + public void Changing_actions_and_focus_never_changes_page_membership() + { + var items = Items(24) + .Select(item => item with { Description = "Short." }).ToArray(); + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.C, ConsoleKey.A, ConsoleKey.DownArrow, ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(items, Title); + + Assert.All(terminal.Frames, frame => Assert.Equal(FrameSkillNames(terminal.Frames[0]), FrameSkillNames(frame))); + Assert.Equal(Enumerable.Range(1, 5).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[0])); + Assert.Equal(SkillSpan(terminal, 0, 1).Top, SkillSpan(terminal, 4, 1).Top); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 2, 2, "X").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 2, 1).Style); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_growing_then_shrinking_summary_wraps_without_repaginating_or_leaving_old_footer_rows() + { + var items = Items(100).Select(item => item with { Description = "Short." }).ToArray(); + // At this width the empty uninstall summary fits on one line and the full one does not, + // so selecting everything grows the footer by a row and clearing shrinks it again. + var terminal = new FakeTerminal(windowHeight: 30, windowWidth: 33) + .Press(ConsoleKey.A, ConsoleKey.C, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(items, Title, PickerMode.Uninstall); + + var tops = terminal.CursorTopsAwaitingKey; + Assert.Equal([tops[0], tops[0] + 1, tops[0]], tops); + Assert.All(terminal.Frames, frame => Assert.Equal(FrameSkillNames(terminal.Frames[0]), FrameSkillNames(frame))); + Assert.Contains("100 of 100 selected; 100 to", terminal.Frames[1]); + Assert.Contains("0 of 100 selected; 0 to remove", terminal.Frames[2]); + Assert.True(string.IsNullOrWhiteSpace(terminal.Frames[2].Split(Environment.NewLine)[tops[0]])); + AssertWithinWindow(terminal); + } + + [Fact] + public void Normal_arrows_navigate_skills_even_when_a_description_is_scrollable() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow) + .Press(ConsoleKey.DownArrow, ConsoleKey.UpArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(MixedItems(50, 1, 1), Title); + + Assert.Contains(" desc-01-3", terminal.Frames[1]); + Assert.Contains(" - desc-01-1", terminal.Frames[1]); + Assert.DoesNotContain("desc-01-2", terminal.Frames[1].Split(Environment.NewLine).Select(line => line.Trim())); + Assert.Contains("> [ ] skill-02", terminal.Frames[2]); + Assert.Contains("page 2 of 2", terminal.Frames[2]); + Assert.DoesNotContain("", terminal.Frames[2]); + Assert.Contains("> [ ] skill-01", terminal.Frames[3]); + Assert.Contains(" desc-01-3", terminal.Frames[3]); + Assert.Contains("/", terminal.Frames[3]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Description_scrolling_reaches_every_line_and_clamps_both_ends_without_selecting() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .PressWith(ConsoleModifiers.Control, ConsoleKey.UpArrow, times: 3) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow, times: 80) + .PressWith(ConsoleModifiers.Control, ConsoleKey.UpArrow, times: 80) + .Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var description = string.Join("\n", Enumerable.Range(1, 60).Select(line => $"detail-{line:00}")); + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", description)], Title); + + Assert.Equal("alpha", Assert.Single(chosen!)); + Assert.Equal(terminal.Frames[0], terminal.Frames[3]); + Assert.Contains("detail-60", terminal.Frames[83]); + Assert.Contains(" - detail-01", terminal.Frames[83]); + Assert.DoesNotContain("detail-02", terminal.Frames[83]); + Assert.Equal(terminal.Frames[82], terminal.Frames[83]); + Assert.Equal(terminal.Frames[0], terminal.Frames[163]); + for (var line = 1; line <= 60; line++) + { + Assert.Contains(terminal.Frames, frame => frame.Contains($"detail-{line:00}", StringComparison.Ordinal)); + } + + Assert.All(terminal.Frames.Take(164), frame => Assert.Contains("> [ ] alpha", frame)); + Assert.Contains("> [X] alpha", terminal.Frames[164]); + Assert.All(terminal.Frames, frame => Assert.Contains("/", frame)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Scroll_controls_are_ignored_and_not_advertised_when_every_line_fits() + { + var terminal = new FakeTerminal().PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow) + .PressWith(ConsoleModifiers.Control, ConsoleKey.UpArrow) + .Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Empty(chosen!); + Assert.All(terminal.Frames, frame => + { + Assert.Contains("> [ ] skill-01", frame); + Assert.DoesNotContain("", frame); + Assert.Equal(terminal.Frames[0], frame); + }); + } + + [Fact] + public void Resizing_down_and_up_preserves_focus_and_selections_and_erases_old_cells() + { + var terminal = new FakeTerminal(windowHeight: 40, windowWidth: 160) + .Press(ConsoleKey.End, ConsoleKey.Spacebar, ConsoleKey.Home, ConsoleKey.Spacebar, ConsoleKey.End) + .Resize(windowHeight: 18, windowWidth: 46) + .Resize(windowHeight: 40, windowWidth: 160) + .Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Equal(["skill-01", "skill-24"], chosen!); + Assert.Equal(3, terminal.ViewportClears); + Assert.Equal((46, 18), terminal.FrameSizes[6]); + Assert.Contains("> [X] skill-24", terminal.Frames[6]); + Assert.DoesNotContain("skill-01", terminal.Frames[6]); + Assert.DoesNotContain("Package.24", terminal.Frames[6]); + Assert.Contains("> [X] skill-24 - No description provided.", terminal.Frames[7]); + Assert.Contains("[X] skill-01 - No description provided.", terminal.Frames[7]); + Assert.Equal(24, Rows(terminal.Frames[7]).Count); + Assert.DoesNotContain("change page", terminal.Frames[7]); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Resizing_during_a_redraw_restarts_the_frame_without_losing_selection(bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 50, windowWidth: 120) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var resized = false; + terminal.BeforeOperation = operation => + { + if (!resized && operation == nameof(FakeTerminal.SetStyle) && + terminal.KeysRead.Count == 1 && terminal.Writes.LastOrDefault()?.Text.StartsWith('[') == true) + { + resized = true; + terminal.ResizeNow(windowHeight: 18, windowWidth: 46); + } + }; + + var selected = new SkillPicker(terminal).Choose( + MixedItems(8, 8, 8), Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.True(resized); + Assert.Equal("skill-01", Assert.Single(selected!)); + Assert.Equal(2, terminal.ViewportClears); + Assert.Contains("page 1 of 3", terminal.Frames[^1]); + Assert.Contains(PickerLayout.PrimaryHelp, terminal.Frames[^1]); + Assert.Contains("1 of 3 selected", terminal.Frames[^1]); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Continuation_lines_use_the_width_beneath_the_skill_text(bool color) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 46) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var selected = new SkillPicker(terminal).Choose( + [new SkillPickerItem("longer-skill", "P", "1", + "One two three four five six seven eight nine ten.")], Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + // Rows start in the same column with and without color, so they wrap the same way. + Assert.EndsWith("longer-skill - One two three four five", lines[2]); + Assert.Equal(new string(' ', 6) + "six seven eight nine ten.", lines[3]); + Assert.Equal(string.Empty, lines[4]); + Assert.Equal("longer-skill", Assert.Single(selected!)); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false, true)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(true, false)] + public void Resizing_without_a_key_reflows_before_input_and_preserves_focus_and_selections( + bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 32, windowWidth: 120) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.End, ConsoleKey.Spacebar) + .ResizeWhileWaiting(windowHeight: 18, windowWidth: 46) + .WaitWithoutKey(times: 2) + .Press(ConsoleKey.Enter); + const string focused = "> [X] skill-24"; + // Without color there is no legend line, so each page holds one more skill: all 24 fit + // on one page at 120x32, and the narrow window needs four pages rather than five. + var lastPage = color ? "page 5 of 5" : "page 4 of 4"; + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey) && terminal.KeysRead.Count == 3) + { + // Assert the resize is already visible BEFORE the next real key is consumed. + Assert.Equal(2, terminal.ViewportClears); + Assert.Contains(lastPage, terminal.Screen); + Assert.Contains(focused, terminal.Screen); + Assert.Contains("(Press to select, to accept)", terminal.Screen); + } + }; + var items = Items(24); + + var chosen = new SkillPicker(terminal).Choose(items, Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal(["skill-01", "skill-24"], chosen!); + if (color) + { + Assert.Contains("page 2 of 2", terminal.Frames[3]); + } + else + { + Assert.DoesNotContain("page", terminal.Frames[3]); + } + + Assert.Equal((46, 18), terminal.FrameSizes[4]); + Assert.Contains(lastPage, terminal.Frames[4]); + Assert.Contains(focused, terminal.Frames[4]); + Assert.DoesNotContain("Package.24", terminal.Frames[4]); + Assert.Equal(color ? TerminalStyle.Focus : TerminalStyle.Default, + SkillSpan(terminal, 4, 24).Style); + Assert.Equal(color ? TerminalStyle.Selected : TerminalStyle.Default, CheckboxSpan(terminal, 4, 24, "X").Style); + Assert.Equal(terminal.Frames[4], terminal.Frames[5]); + Assert.Equal(terminal.Frames[4], terminal.Frames[6]); + Assert.Empty(terminal.FrameWrites[5]); + Assert.Empty(terminal.FrameWrites[6]); + Assert.Equal([ConsoleKey.Spacebar, ConsoleKey.End, ConsoleKey.Spacebar, ConsoleKey.Enter], + terminal.KeysRead.Select(key => key.Key)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Idle_input_waits_are_bounded_and_do_not_repaint_unchanged_frames() + { + var terminal = new FakeTerminal().WaitWithoutKey(times: 3).Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Empty(chosen!); + Assert.Equal(4, terminal.InputTimeouts.Count); + Assert.All(terminal.InputTimeouts, timeout => Assert.Equal(TimeSpan.FromMilliseconds(100), timeout)); + Assert.All(terminal.Frames, frame => Assert.Equal(terminal.Frames[0], frame)); + Assert.All(terminal.FrameWrites.Skip(1), writes => Assert.Empty(writes)); + Assert.Equal(1, terminal.ViewportClears); + Assert.Equal(ConsoleKey.Enter, Assert.Single(terminal.KeysRead).Key); + AssertWithinWindow(terminal); + } + + [Fact] + public void An_idle_resize_can_reveal_a_complete_description_and_remove_the_scroll_hint() + { + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.Spacebar) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow, times: 5) + .ResizeWhileWaiting(windowHeight: 100, windowWidth: 120) + .WaitWithoutKey(times: 2) + .Press(ConsoleKey.Enter); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey) && terminal.KeysRead.Count == 6) + { + Assert.Contains("desc-01-60", terminal.Screen); + Assert.DoesNotContain("", terminal.Screen); + Assert.Contains("> [X] skill-01 - desc-01-1", terminal.Screen); + } + }; + + var chosen = new SkillPicker(terminal).Choose(MixedItems(60), Title); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains(" desc-01-7", terminal.Frames[6]); + Assert.Contains(" - desc-01-1", terminal.Frames[6]); + Assert.EndsWith(" - desc-01-1", terminal.Frames[7].Split(Environment.NewLine)[2].TrimEnd()); + Assert.Contains("desc-01-60", terminal.Frames[7]); + Assert.DoesNotContain("", terminal.Frames[7]); + Assert.Equal(terminal.Frames[7], terminal.Frames[8]); + Assert.Equal(terminal.Frames[7], terminal.Frames[9]); + Assert.Empty(terminal.FrameWrites[8]); + Assert.Empty(terminal.FrameWrites[9]); + Assert.Equal(7, terminal.KeysRead.Count); + Assert.Equal(2, terminal.ViewportClears); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_page_key_received_with_a_resize_uses_the_new_page_boundaries() + { + var terminal = new FakeTerminal(windowHeight: 40, windowWidth: 200) + .Press(ConsoleKey.DownArrow, times: 2) + .ResizeBeforeKey(ConsoleKey.PageDown, windowHeight: 18, windowWidth: 100) + .Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Equal("skill-11", Assert.Single(chosen!)); + Assert.Contains("> [ ] skill-11", terminal.Frames[3]); + Assert.Contains("page 2 of 3", terminal.Frames[3]); + Assert.DoesNotContain("skill-01", terminal.Frames[3]); + Assert.Equal(2, terminal.ViewportClears); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void Resizing_with_an_exit_key_still_redraws_and_restores_the_terminal(ConsoleKey exit) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .ResizeBeforeKey(exit, windowHeight: 18, windowWidth: 46); + + var chosen = new SkillPicker(terminal).Choose(Items(4), Title); + + if (exit == ConsoleKey.Enter) + { + Assert.Empty(chosen!); + } + else + { + Assert.Null(chosen); + } + + Assert.Equal(2, terminal.ViewportClears); + Assert.Contains("(Press to select, to accept)", terminal.LastPickerScreen); + Assert.All(terminal.LastPickerScreen.Split(Environment.NewLine), line => Assert.True(TerminalText.Width(line) < 46)); + Assert.Empty(terminal.Screen); + Assert.InRange(terminal.FinalCursorTop, 0, 17); + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + Assert.Equal(TerminalStyle.Default, terminal.CurrentStyle); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_scrolled_description_is_clamped_after_resizing_and_fully_shown_when_it_fits() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .Press(ConsoleKey.Spacebar) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow, times: 5) + .Resize(windowHeight: 18, windowWidth: 46) + .Resize(windowHeight: 100, windowWidth: 160) + .Resize(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(MixedItems(60), Title); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains(" desc-01-7", terminal.Frames[7]); + Assert.Contains(" - desc-01-1", terminal.Frames[7]); + Assert.Contains(" - desc-01-1", terminal.Frames[8]); + Assert.Contains("desc-01-60", terminal.Frames[8]); + Assert.DoesNotContain("", terminal.Frames[8]); + Assert.Contains(" - desc-01-1", terminal.Frames[9]); + Assert.DoesNotContain("desc-01-60", terminal.Frames[9]); + Assert.Contains("/", terminal.Frames[9]); + Assert.All(terminal.Frames.Skip(1), frame => Assert.Contains("> [X] skill-01", frame)); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Resizing_to_an_impossible_viewport_fails_safely_instead_of_drawing_outside_it(bool idle) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + if (idle) + { + terminal.ResizeWhileWaiting(windowHeight: 4, windowWidth: 5); + } + else + { + terminal.Resize(windowHeight: 4, windowWidth: 5); + } + + var error = Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains("5x4", error.Message); + Assert.Contains("Enlarge the window", error.Message); + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + Assert.Equal(ConsoleColor.Gray, terminal.Foreground); + Assert.Equal(ConsoleColor.Black, terminal.Background); + Assert.Equal(TerminalStyle.Default, terminal.CurrentStyle); + Assert.InRange(terminal.FinalCursorTop, 0, 3); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(46, 18, true)] + [InlineData(46, 18, false)] + [InlineData(80, 24, true)] + [InlineData(80, 24, false)] + [InlineData(160, 40, true)] + [InlineData(240, 80, true)] + public void All_navigation_and_selection_keys_stay_inside_the_viewport( + int width, int height, bool color) + { + var terminal = new FakeTerminal(windowHeight: height, windowWidth: width) { SupportsColor = color }; + terminal.Press( + ConsoleKey.DownArrow, ConsoleKey.RightArrow, ConsoleKey.PageDown, ConsoleKey.UpArrow, + ConsoleKey.PageUp, ConsoleKey.LeftArrow, ConsoleKey.End, ConsoleKey.Home, ConsoleKey.A, + ConsoleKey.C, ConsoleKey.Spacebar, ConsoleKey.Enter); + var items = MixedItems(1, 3, 2, 4, 1, 2, 3, 1, 5, 2, 1, 4, 2, 3); + + var chosen = new SkillPicker(terminal).Choose(items, Title); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains("[X] skill-01", terminal.Frames[^1]); + Assert.Contains("(Press to select, to accept)", terminal.Frames[^1]); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_picker_started_near_the_bottom_does_not_scroll_the_shell_buffer() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + terminal.SetCursorPosition(0, 16); + terminal.Write("previous prompt"); + var before = terminal.Screen; + + new SkillPicker(terminal).Choose(Items(3), Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine); + Assert.Equal(Title, lines[0].TrimEnd()); + Assert.Contains("> [ ] skill-01", lines[2]); + Assert.DoesNotContain("previous prompt", terminal.Frames[0]); + Assert.Equal(11, terminal.CursorTopAwaitingKey); + Assert.Equal(before, terminal.Screen); + Assert.Equal(16, terminal.FinalCursorTop); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_small_frame_is_separate_from_preceding_output_and_restores_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + terminal.Write("earlier output"); + terminal.SetCursorPosition(0, 3); + var before = terminal.Screen; + + new SkillPicker(terminal).Choose(Items(3), Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine); + Assert.Equal(Title, lines[0].TrimEnd()); + Assert.DoesNotContain("earlier output", terminal.Frames[0]); + Assert.Equal(11, terminal.CursorTopAwaitingKey); + Assert.Equal(before, terminal.Screen); + Assert.Equal(3, terminal.FinalCursorTop); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void Exit_restores_the_original_colors_style_cursor_and_Ctrl_C_ownership(ConsoleKey exit) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, exit); + terminal.SetStyle(TerminalStyle.Muted); + terminal.Foreground = ConsoleColor.Yellow; + terminal.Background = ConsoleColor.DarkMagenta; + terminal.CursorVisible = false; + terminal.TreatControlCAsInput = true; + var original = terminal.CaptureState(); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(original, terminal.CaptureState()); + } + + [Fact] + public void Ctrl_C_restores_colors_after_a_colored_selection() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A) + .PressWith(ConsoleModifiers.Control, ConsoleKey.C); + var original = terminal.CaptureState(); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Null(chosen); + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(original, terminal.CaptureState()); + } + + [Theory] + [InlineData("UseUtf8Output")] + [InlineData("EnterInteractiveScreen")] + [InlineData("CursorVisible")] + [InlineData("TreatControlCAsInput")] + [InlineData("SetStyle")] + [InlineData("Write")] + [InlineData("SetCursorPosition")] + [InlineData("TryReadKey")] + [InlineData("ReadKey")] + [InlineData("ClearViewport")] + public void Exceptions_in_setup_render_input_or_resize_restore_all_terminal_state(string operation) + { + var terminal = new FakeTerminal() + .Resize(windowHeight: 24, windowWidth: 80) + .Press(ConsoleKey.Enter); + terminal.Foreground = ConsoleColor.Yellow; + terminal.Background = ConsoleColor.DarkMagenta; + var original = terminal.CaptureState(); + var failed = false; + terminal.BeforeOperation = current => + { + if (!failed && current == operation) + { + failed = true; + throw new IOException($"failure in {operation}"); + } + }; + + var error = Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal($"failure in {operation}", error.Message); + Assert.True(failed); + Assert.Equal(original, terminal.CaptureState()); + Assert.False(terminal.IsInteractiveScreen); + Assert.Equal(terminal.ScreenEntries, terminal.ScreenExits); + } + + [Fact] + public void An_input_exception_after_an_action_span_does_not_leak_its_color() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey) && terminal.Frames.Count == 2) + { + throw new IOException("input disappeared"); + } + }; + + Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(TerminalStyle.Default, terminal.CurrentStyle); + Assert.Equal(ConsoleColor.Gray, terminal.Foreground); + Assert.Equal(ConsoleColor.Black, terminal.Background); + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + Assert.Equal(12, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + } + + [Fact] + public void A_failed_colored_write_restores_style_and_parks_below_the_last_complete_frame() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + terminal.Foreground = ConsoleColor.Yellow; + terminal.Background = ConsoleColor.DarkMagenta; + var original = terminal.CaptureState(); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.Write) && terminal.CurrentStyle == TerminalStyle.Selected) + { + throw new IOException("colored write failed"); + } + }; + + var error = Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal("colored write failed", error.Message); + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(original, terminal.CaptureState()); + Assert.Equal(12, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + Assert.Contains("Blue X: selected", terminal.LastPickerScreen); + Assert.Empty(terminal.Screen); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void UTF8_output_is_scoped_to_the_picker_and_the_exact_original_encoding_is_restored(ConsoleKey exit) + { + var originalEncoding = (Encoding)Encoding.Latin1.Clone(); + originalEncoding.EncoderFallback = EncoderFallback.ExceptionFallback; + var terminal = new FakeTerminal { OutputEncoding = originalEncoding }; + terminal.Press(ConsoleKey.Spacebar, exit); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey)) + { + Assert.Equal(Encoding.UTF8.CodePage, terminal.OutputEncoding.CodePage); + Assert.Empty(terminal.OutputEncoding.GetPreamble()); + } + }; + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Same(originalEncoding, terminal.OutputEncoding); + Assert.Same(EncoderFallback.ExceptionFallback, terminal.OutputEncoding.EncoderFallback); + Assert.Equal([Encoding.UTF8.CodePage, originalEncoding.CodePage], + terminal.EncodingChanges.Select(encoding => encoding.CodePage)); + Assert.All(terminal.Writes, write => Assert.Equal(Encoding.UTF8.CodePage, write.OutputCodePage)); + } + + [Fact] + public void Ctrl_C_restores_the_original_output_encoding() + { + var terminal = new FakeTerminal { OutputEncoding = Encoding.Latin1 }; + terminal.Press(ConsoleKey.Spacebar).PressWith(ConsoleModifiers.Control, ConsoleKey.C); + var original = terminal.CaptureState(); + + Assert.Null(new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal(original, terminal.CaptureState()); + Assert.Equal([Encoding.UTF8.CodePage, Encoding.Latin1.CodePage], + terminal.EncodingChanges.Select(encoding => encoding.CodePage)); + } + + [Theory] + [InlineData(false, true)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(true, false)] + public void Unicode_descriptions_are_encoded_losslessly_and_cannot_emit_terminal_controls( + bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 160) + { + OutputEncoding = Encoding.Latin1, + SupportsColor = color, + }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + const string Description = "Café, 測試, 🧪, Cafe\u0301. \x1b[2JUNICODE-END"; + const string ExpectedSpan = " - Café, 測試, 🧪, Cafe\u0301. UNICODE-END"; + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", Description)], Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal("alpha", Assert.Single(chosen!)); + var description = Assert.Single(terminal.FrameWrites[0], + write => write.Text.StartsWith(" - ", StringComparison.Ordinal)); + Assert.Equal(ExpectedSpan, description.Text); + Assert.Equal(Encoding.UTF8.GetBytes(ExpectedSpan), description.Bytes); + Assert.Equal(Encoding.UTF8.CodePage, description.OutputCodePage); + Assert.DoesNotContain((byte)0x1b, description.Bytes); + Assert.Equal(color ? TerminalStyle.Focus : TerminalStyle.Default, description.Style); + Assert.Contains("測試", terminal.Frames[0]); + Assert.Contains("🧪", terminal.Frames[0]); + Assert.Contains("Cafe\u0301", terminal.Frames[0]); + Assert.DoesNotContain('?', description.Text); + Assert.Same(Encoding.Latin1, terminal.OutputEncoding); + AssertWithinWindow(terminal); + } + + [Fact] + public void The_test_terminal_observes_legacy_encoding_loss_instead_of_assuming_Unicode_output() + { + var terminal = new FakeTerminal { OutputEncoding = Encoding.Latin1 }; + const string Text = "Café, 測試, 🧪, Cafe\u0301."; + + terminal.Write(Text); + + var write = Assert.Single(terminal.Writes); + Assert.Equal(Encoding.Latin1.GetBytes(Text), write.Bytes); + Assert.Equal(Encoding.Latin1.GetString(write.Bytes), terminal.Screen); + Assert.DoesNotContain("測試", terminal.Screen); + Assert.DoesNotContain("🧪", terminal.Screen); + Assert.DoesNotContain("Cafe\u0301", terminal.Screen); + Assert.NotEqual(Text, terminal.Screen); + } + + [Fact] + public void Noninteractive_and_empty_paths_do_not_change_output_encoding() + { + var redirected = new FakeTerminal { IsRedirected = true, OutputEncoding = Encoding.Latin1 }; + var empty = new FakeTerminal { IsRedirected = true, OutputEncoding = Encoding.Latin1 }; + + Assert.Throws(() => new SkillPicker(redirected).Choose(Items(1), Title)); + Assert.Empty(new SkillPicker(empty).Choose([], Title)!); + + Assert.Empty(redirected.EncodingChanges); + Assert.Empty(empty.EncodingChanges); + Assert.Same(Encoding.Latin1, redirected.OutputEncoding); + Assert.Same(Encoding.Latin1, empty.OutputEncoding); + Assert.Empty(redirected.Writes); + Assert.Empty(empty.Writes); + Assert.Equal(0, redirected.ScreenEntries); + Assert.Equal(0, empty.ScreenEntries); + } + + [Fact] + public void Authored_unicode_names_use_display_cells_for_column_alignment() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80).Press(ConsoleKey.A, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose( + [ + new SkillPickerItem("猫", "P", "1", "First."), + new SkillPickerItem("ab", "P", "1", "Second."), + new SkillPickerItem("👩🏽‍💻", "P", "1", "Third."), + ], + Title); + + Assert.Equal(["猫", "ab", "👩🏽‍💻"], chosen!); + var descriptions = terminal.FrameWrites[0].Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal)).ToArray(); + Assert.Equal([8, 8, 8], descriptions.Select(write => write.Left)); + Assert.Equal( + [TerminalStyle.Focus, TerminalStyle.Default, TerminalStyle.Default], + descriptions.Select(write => write.Style)); + Assert.Contains("> [X] 猫 - First.", terminal.Frames[1]); + Assert.Contains("[X] 👩🏽‍💻 - Third.", terminal.Frames[1]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Wrapped_unicode_descriptions_keep_every_grapheme_and_do_not_split_surrogates() + { + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46).Press(ConsoleKey.Enter); + var description = string.Concat(Enumerable.Repeat("界e\u0301👩🏽‍💻", 12)); + + new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1.2.3", description)], Title); + + var indent = new string(' ', 6); + var rendered = terminal.FrameWrites[0] + .Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal) || + write.Text.StartsWith(indent, StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(write.Text)) + .Select(write => write.Text.StartsWith(" - ", StringComparison.Ordinal) ? write.Text[3..] : write.Text[6..]) + .ToArray(); + Assert.Equal(description, string.Concat(rendered)); + Assert.Equal(TerminalText.Elements(description), rendered.SelectMany(TerminalText.Elements)); + Assert.All(rendered, line => Assert.DoesNotContain('\ufffd', line)); + Assert.DoesNotContain("...", terminal.Frames[0]); + AssertWithinWindow(terminal); + } + + [Fact] + public void All_author_metadata_is_sanitized_but_returned_skill_identity_is_unchanged() + { + const string Name = "unsafe\x1b[2Jskill\r\nname\u202e"; + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 120) + .Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var item = new SkillPickerItem(Name, "P\a", "\u009b2J1", + "\x1b]8;;malicious\aVisible\x1b]8;;\x1b\\\nrow\tend\u202e\0"); + + var chosen = new SkillPicker(terminal).Choose( + [item], "\x1b[31mTitle\x1b[0m\nnext\tline"); + + Assert.Equal(Name, Assert.Single(chosen!)); + Assert.Contains("Title next line", terminal.Frames[0]); + Assert.Contains("unsafeskill name - Visible", terminal.Frames[0]); + Assert.Contains("row end", terminal.Frames[0]); + Assert.DoesNotContain("malicious", terminal.Frames[0]); + Assert.DoesNotContain('\u202e', terminal.Frames[0]); + Assert.All(terminal.Frames, frame => + Assert.DoesNotContain(frame.Replace(Environment.NewLine, string.Empty), char.IsControl)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Unicode_name_clipping_keeps_whole_graphemes_and_is_not_a_fixed_width_cap() + { + var name = string.Concat(Enumerable.Repeat("👩🏽‍💻e\u0301界", 16)); + var item = new SkillPickerItem(name, "P", "1", "Short."); + var narrow = new FakeTerminal(windowHeight: 24, windowWidth: 46).Press(ConsoleKey.Enter); + var wide = new FakeTerminal(windowHeight: 24, windowWidth: 240).Press(ConsoleKey.Enter); + + new SkillPicker(narrow).Choose([item], Title); + new SkillPicker(wide).Choose([item], Title); + + Assert.Contains("...", narrow.Frames[0]); + Assert.DoesNotContain('\ufffd', narrow.Frames[0]); + Assert.Contains(name, wide.Frames[0]); + Assert.DoesNotContain("...", wide.Frames[0]); + AssertWithinWindow(narrow); + AssertWithinWindow(wide); + } + + private static IReadOnlyList MixedItems(params int[] heights) => + Items(heights.Length).Select((item, index) => item with + { + Description = string.Join("\n", + Enumerable.Range(1, heights[index]).Select(line => $"desc-{index + 1:00}-{line}")), + }).ToArray(); + + private static string[] FrameSkillNames(string frame) => Rows(frame) + .Select(row => System.Text.RegularExpressions.Regex.Match(row, @"\bskill-\d+\b").Value).ToArray(); + + private static void AssertWithinWindow(FakeTerminal terminal) + { + Assert.All(terminal.Writes, write => + { + Assert.InRange(write.Left, 0, write.WindowWidth - 1); + Assert.InRange(write.Top, 0, write.WindowHeight - 1); + Assert.True(write.Left + TerminalText.Width(write.Text) < write.WindowWidth, + $"Write used the wrap column of {write.WindowWidth}x{write.WindowHeight}: '{write.Text}'"); + }); + for (var frame = 0; frame < terminal.Frames.Count; frame++) + { + var (width, height) = terminal.FrameSizes[frame]; + var lines = terminal.Frames[frame].Split(Environment.NewLine); + Assert.True(lines.Length <= height); + Assert.All(lines, line => Assert.True(TerminalText.Width(line) < width)); + var lastContent = Array.FindLastIndex(lines, line => !string.IsNullOrWhiteSpace(line)); + Assert.True(lastContent < height - 1); + Assert.Equal(lastContent + 1, terminal.CursorTopsAwaitingKey[frame]); + } + } + + private static IReadOnlyList Items(int count) => + [ + .. Enumerable.Range(1, count).Select(number => + new SkillPickerItem($"skill-{number:00}", $"Package.{number}", "1.0.0")), + ]; +} diff --git a/dotnet-package-skills/tests/TargetLocatorTests.cs b/dotnet-package-skills/tests/TargetLocatorTests.cs new file mode 100644 index 0000000..52db0b3 --- /dev/null +++ b/dotnet-package-skills/tests/TargetLocatorTests.cs @@ -0,0 +1,118 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class TargetLocatorTests +{ + [Fact] + public void Detect_prefers_a_solution_over_a_project() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateFile("MyApp.csproj"); + + Assert.EndsWith("MyApp.sln", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_prefers_slnx_over_sln() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateFile("MyApp.slnx"); + + Assert.EndsWith("MyApp.slnx", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_prefers_the_top_level_over_a_nested_solution() + { + using var temp = new TempDirectory(); + temp.CreateFile("Root.sln"); + temp.CreateFile("nested/Inner.sln"); + + Assert.EndsWith("Root.sln", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_descends_when_nothing_is_at_the_top_level() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp/MyApp.csproj"); + + Assert.EndsWith("MyApp.csproj", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_ignores_build_output_directories() + { + using var temp = new TempDirectory(); + + // Project files copied into obj/ during restore would otherwise win by sort order. + temp.CreateFile("obj/Aaa.csproj"); + temp.CreateFile("src/Real.csproj"); + + Assert.EndsWith("Real.csproj", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_finds_fsproj_and_vbproj_too() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp.fsproj"); + + Assert.EndsWith("MyApp.fsproj", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_explains_what_to_do_when_there_is_no_target() + { + using var temp = new TempDirectory(); + + var exception = Assert.Throws(() => TargetLocator.Detect(temp.Path)); + + Assert.Contains("--target", exception.Message); + } + + [Fact] + public void Resolve_accepts_a_path_relative_to_the_working_directory() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp/MyApp.csproj"); + + var resolved = TargetLocator.Resolve("src/MyApp/MyApp.csproj", temp.Path); + + Assert.True(Path.IsPathRooted(resolved)); + Assert.True(File.Exists(resolved)); + } + + [Fact] + public void Resolve_searches_within_a_directory_that_was_passed_as_the_target() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp/MyApp.csproj"); + + Assert.EndsWith("MyApp.csproj", TargetLocator.Resolve("src", temp.Path)); + } + + [Fact] + public void Resolve_rejects_a_file_that_is_not_a_project_or_solution() + { + using var temp = new TempDirectory(); + temp.CreateFile("notes.txt"); + + var exception = Assert.Throws(() => TargetLocator.Resolve("notes.txt", temp.Path)); + + Assert.Contains(".csproj", exception.Message); + } + + [Fact] + public void Resolve_reports_a_missing_target_by_full_path() + { + using var temp = new TempDirectory(); + + var exception = Assert.Throws(() => TargetLocator.Resolve("Ghost.sln", temp.Path)); + + Assert.Contains("Ghost.sln", exception.Message); + } +} diff --git a/dotnet-package-skills/tests/TempDirectory.cs b/dotnet-package-skills/tests/TempDirectory.cs new file mode 100644 index 0000000..fa4373f --- /dev/null +++ b/dotnet-package-skills/tests/TempDirectory.cs @@ -0,0 +1,61 @@ +namespace DotnetPackageSkills.Tests; + +/// A scratch directory that cleans itself up, for tests that touch the file system. +public sealed class TempDirectory : IDisposable +{ + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "dps-tests-" + Guid.NewGuid().ToString("N")[..12]); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public string Combine(params string[] parts) => System.IO.Path.Combine([Path, .. parts]); + + /// Creates a directory under the temp root and returns its full path. + public string CreateDirectory(params string[] parts) + { + var full = Combine(parts); + Directory.CreateDirectory(full); + return full; + } + + /// Creates a file (and its parent directories) under the temp root. + public string CreateFile(string relativePath, string content = "") + { + var full = Combine(relativePath.Split('/')); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full)!); + File.WriteAllText(full, content); + return full; + } + + /// Builds an extracted-package layout with a bundled skill, mirroring the NuGet cache. + public string CreatePackageWithSkill(string packageId, string version, params string[] skillNames) + { + var packageDirectory = CreateDirectory("packages", packageId.ToLowerInvariant(), version); + + foreach (var skillName in skillNames) + { + CreateFile($"packages/{packageId.ToLowerInvariant()}/{version}/skills/{skillName}/SKILL.md", + $"---\nname: {skillName}\n---\n"); + } + + return packageDirectory; + } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch (IOException) + { + // A locked file in a temp directory is not worth failing a test over. + } + } +} diff --git a/dotnet-package-skills/tests/TerminalTextTests.cs b/dotnet-package-skills/tests/TerminalTextTests.cs new file mode 100644 index 0000000..11b5cde --- /dev/null +++ b/dotnet-package-skills/tests/TerminalTextTests.cs @@ -0,0 +1,211 @@ +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +public class TerminalTextTests +{ + [Theory] + [InlineData("", 0)] + [InlineData("plain", 5)] + [InlineData("界", 2)] + [InlineData("A", 2)] + [InlineData("e\u0301", 1)] + [InlineData("😀", 2)] + [InlineData("👩🏽‍💻", 2)] + [InlineData("🇺🇸", 2)] + [InlineData("❤️", 2)] + [InlineData("♥", 1)] + [InlineData("1️⃣", 2)] + [InlineData("𐍈", 1)] + [InlineData("가", 2)] + [InlineData("\u0301", 0)] + [InlineData("a\u200d", 1)] + [InlineData("\u303f", 1)] + public void Width_counts_display_cells_not_UTF16_code_units(string text, int cells) + { + Assert.Equal(cells, TerminalText.Width(text)); + } + + [Fact] + public void Wrapping_prefers_word_boundaries() + { + Assert.Equal(["One two three", "four five"], TerminalText.Wrap("One two three four five", 13)); + } + + [Fact] + public void Wrapping_keeps_explicit_paragraph_breaks() + { + Assert.Equal(["one", "", "two", "three"], TerminalText.Wrap("one\n\ntwo three", 6)); + } + + [Fact] + public void Long_words_are_split_without_losing_their_tail() + { + Assert.Equal(["abcde", "fghij", "kl"], TerminalText.Wrap("abcdefghijkl", 5)); + } + + [Fact] + public void Wrapping_never_splits_a_combining_sequence_flag_or_joined_emoji() + { + const string Text = "ab👩🏽‍💻e\u0301🇺🇸界"; + + var lines = TerminalText.Wrap(Text, 3); + + Assert.Equal(["ab", "👩🏽‍💻e\u0301", "🇺🇸", "界"], lines); + Assert.Equal(Text, string.Concat(lines)); + Assert.All(lines, line => Assert.InRange(TerminalText.Width(line), 1, 3)); + } + + [Fact] + public void An_exactly_fitting_grapheme_is_not_replaced_or_split() + { + Assert.Equal(["👩🏽‍💻", "🇺🇸", "界"], TerminalText.Wrap("👩🏽‍💻🇺🇸界", 2)); + } + + [Fact] + public void A_column_that_cannot_fit_one_grapheme_is_rejected_instead_of_losing_it() + { + Assert.Throws(() => TerminalText.Wrap("界", 1)); + Assert.Throws(() => TerminalText.Wrap("text", 0)); + } + + [Fact] + public void Continuation_lines_can_use_more_space_than_the_first_line() + { + Assert.Equal( + ["One two", "three four five six", "seven eight nine"], + TerminalText.Wrap("One two three four five six seven eight nine", 7, 19)); + } + + [Fact] + public void Wider_continuations_keep_paragraph_breaks_and_long_word_tails() + { + Assert.Equal( + ["abc", "defgh", "", "one two three"], + TerminalText.Wrap("abcdefgh\n\none two three", 3, 13)); + } + + [Fact] + public void Wider_continuations_preserve_unicode_graphemes() + { + const string text = "界👩🏽‍💻e\u0301界"; + + var lines = TerminalText.Wrap(text, 2, 5); + + Assert.Equal(["界", "👩🏽‍💻e\u0301界"], lines); + Assert.Equal(TerminalText.Elements(text), lines.SelectMany(TerminalText.Elements)); + Assert.Equal(["a", "界"], TerminalText.Wrap("a界", 1, 2)); + } + + [Fact] + public void Both_wrapping_widths_must_be_positive() + { + Assert.Throws(() => TerminalText.Wrap("text", 0, 10)); + Assert.Throws(() => TerminalText.Wrap("text", 10, 0)); + } + + [Theory] + [InlineData(6, "e\u0301界...")] + [InlineData(4, "e\u0301...")] + [InlineData(3, "...")] + [InlineData(2, "..")] + [InlineData(1, ".")] + [InlineData(0, "")] + public void Clipping_names_preserves_graphemes_and_reserves_the_ellipsis(int width, string expected) + { + var clipped = TerminalText.Clip("e\u0301界👩🏽‍💻rest", width); + + Assert.Equal(expected, clipped); + Assert.True(TerminalText.Width(clipped) <= width); + Assert.DoesNotContain('\ufffd', clipped); + } + + [Fact] + public void Fitting_text_is_not_given_an_ellipsis() + { + Assert.Equal("👩🏽‍💻e\u0301", TerminalText.Clip("👩🏽‍💻e\u0301", 3)); + } + + [Fact] + public void Padding_aligns_cells_instead_of_surrogates_or_combining_marks() + { + Assert.Equal("界e\u0301 ", TerminalText.PadRight("界e\u0301", 6)); + } + + [Fact] + public void Sanitizing_strips_ANSI_commands_hyperlinks_controls_and_direction_overrides() + { + const string Text = + "before\x1b[31mRED\x1b[0m\x1b]8;;https://invalid.example\a" + + "link\x1b]8;;\x1b\\\r\nnext\tpart\0\a\u007f\u009b2J\u202eafter\u2066\u2069"; + + Assert.Equal("beforeREDlink\nnext partafter", TerminalText.Sanitize(Text, multiline: true)); + Assert.Equal("beforeREDlink next partafter", TerminalText.Sanitize(Text)); + } + + [Fact] + public void Sanitizing_trims_boundaries_by_default_but_can_preserve_them() + { + const string Text = " \t\u001b[31mfirst\u001b[0m\r\n second \r\n"; + const string Preserved = " first\n second \n"; + + Assert.Equal("first\n second", TerminalText.Sanitize(Text, multiline: true)); + Assert.Equal("first\n second", TerminalText.Sanitize(Text, multiline: true, trim: true)); + Assert.Equal(Preserved, TerminalText.Sanitize(Text, multiline: true, trim: false)); + Assert.Equal(Preserved.Replace('\n', ' ').Trim(), TerminalText.Sanitize(Text)); + Assert.Equal(Preserved.Replace('\n', ' '), TerminalText.Sanitize(Text, trim: false)); + } + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" \r\n\t", " \n ")] + public void Preserving_boundaries_keeps_blank_text_without_inventing_content(string? text, string expected) + { + Assert.Equal(string.Empty, TerminalText.Sanitize(text, multiline: true)); + Assert.Equal(expected, TerminalText.Sanitize(text, multiline: true, trim: false)); + } + + [Fact] + public void Preserving_boundaries_still_discards_unterminated_escape_payloads() + { + const string Text = " before \u001b]52;c;SECRET\r\n"; + + Assert.Equal("before", TerminalText.Sanitize(Text, multiline: true)); + Assert.Equal(" before ", TerminalText.Sanitize(Text, multiline: true, trim: false)); + } + + [Theory] + [InlineData("a\x1bPignored\x1b\\b", "ab")] + [InlineData("a\u009dignored\u009cb", "ab")] + [InlineData("a\x1b(0b", "ab")] + [InlineData("a\x1b[999mvisible", "avisible")] + [InlineData("a\x1b]unterminated", "a")] + [InlineData("a\x1b", "a")] + public void Terminal_escape_strings_do_not_leak_their_payload(string input, string expected) + { + Assert.Equal(expected, TerminalText.Sanitize(input)); + } + + [Fact] + public void Authored_unicode_is_not_forced_to_ASCII() + { + const string Text = "👩🏽‍💻 Café e\u0301 中文 🇨🇦"; + + Assert.Equal(Text, TerminalText.Sanitize(Text)); + } + + [Fact] + public void Unpaired_surrogates_become_visible_replacement_characters() + { + Assert.Equal("before\ufffdafter\ufffd", TerminalText.Sanitize("before\ud800after\udfff")); + } + + [Fact] + public void Line_separators_are_normalized_without_allowing_terminal_control_characters() + { + Assert.Equal("one\ntwo\nthree\nfour", + TerminalText.Sanitize("one\r\ntwo\rthree\u2028four", multiline: true)); + Assert.Equal("one two three four", TerminalText.Sanitize("one\r\ntwo\rthree\u2028four")); + } +} diff --git a/eng/Build.props b/eng/Build.props index 14b261c..c3f409b 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -1,5 +1,6 @@ + diff --git a/eng/Signing.props b/eng/Signing.props new file mode 100644 index 0000000..0b041c0 --- /dev/null +++ b/eng/Signing.props @@ -0,0 +1,3 @@ + + + diff --git a/eng/Versions.props b/eng/Versions.props index 6521442..e793ad5 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -3,4 +3,5 @@ 0.1.0 beta + diff --git a/eng/pipelines/dotnet-package-skills/Build.props b/eng/pipelines/dotnet-package-skills/Build.props new file mode 100644 index 0000000..b6ca117 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Build.props @@ -0,0 +1,27 @@ + + + false + <_DotnetPackageSkillsReleaseRequested>$([System.Text.RegularExpressions.Regex]::IsMatch('$(DotnetPackageSkillsReleaseBuild)', '^(?i:true)$')) + <_DotnetPackageSkillsValidBuildId>false + <_DotnetPackageSkillsBuildIdShape>$([System.Text.RegularExpressions.Regex]::IsMatch('$(OfficialBuildId)', '^20[0-9]{2}(0[1-9]|1[0-2])(0[1-9]|[12][0-9]|3[01])\.[1-9][0-9]?$')) + + + <_DotnetPackageSkillsBuildYear>$(OfficialBuildId.Substring(0, 4)) + <_DotnetPackageSkillsBuildMonth>$(OfficialBuildId.Substring(4, 2)) + <_DotnetPackageSkillsBuildDay>$(OfficialBuildId.Substring(6, 2)) + <_DotnetPackageSkillsValidBuildId Condition="$(_DotnetPackageSkillsBuildDay) <= $([System.DateTime]::DaysInMonth($(_DotnetPackageSkillsBuildYear), $(_DotnetPackageSkillsBuildMonth)))">true + + + + false + <_DotnetPackageSkillsTrustedBuild>false + <_DotnetPackageSkillsTrustedBuild Condition="'$(OfficialBuild)' == 'true' and + '$(ContinuousIntegrationBuild)' == 'true' and '$(_DotnetPackageSkillsValidBuildId)' == 'true' and + '$(BUILD_REPOSITORY_PROVIDER)' == 'TfsGit' and '$(BUILD_REPOSITORY_NAME)' == 'NuGet-Client.Tools' and + '$(SYSTEM_TEAMPROJECT)' == 'internal' and '$(SYSTEM_COLLECTIONURI)' == 'https://dev.azure.com/dnceng/' and + '$(BUILD_SOURCEBRANCH)' == 'refs/heads/main' and '$(BUILD_REASON)' != '' and '$(BUILD_REASON)' != 'PullRequest'">true + release + + + diff --git a/eng/pipelines/dotnet-package-skills/Build.targets b/eng/pipelines/dotnet-package-skills/Build.targets new file mode 100644 index 0000000..2465129 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Build.targets @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + $(ArtifactsShippingPackagesDir)$(PackageId).$(PackageVersion).nupkg + $(BaseOutputPath)$(Configuration) + $(NuGetPackageRoot)sn\$(SNVersion)\sn.exe + + + + + + <_DotnetPackageSkillsSigningInput Include="$(DotnetPackageSkillsPackagePath)" + PackagePath="$(DotnetPackageSkillsPackagePath)" + PackageVersion="$(PackageVersion)" + BuildOutputPath="$(DotnetPackageSkillsBuildOutputPath)" /> + <_DotnetPackageSkillsSigningInput Include="$(DotnetPackageSkillsBuildOutputPath)\net8.0\dotnet-package-skills.dll" /> + <_DotnetPackageSkillsSigningInput Include="$(DotnetPackageSkillsBuildOutputPath)\net10.0\dotnet-package-skills.dll" /> + + + diff --git a/eng/pipelines/dotnet-package-skills/Invoke-Build.ps1 b/eng/pipelines/dotnet-package-skills/Invoke-Build.ps1 new file mode 100644 index 0000000..4e419f1 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Invoke-Build.ps1 @@ -0,0 +1,118 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('RestoreBuild', 'Test', 'Pack', 'Sign', 'Verify', 'Metadata')] + [string] $Action, + [switch] $CI, + [switch] $Official, + [switch] $ReleaseBuild, + [string] $PackageArtifactPath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Import-Module (Join-Path $PSScriptRoot 'Package-Validation.psm1') -Force +$repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..\..')).Path +Set-Location -LiteralPath $repoRoot + +$properties = @( + "/p:DotnetPackageSkillsReleaseBuild=$($ReleaseBuild.IsPresent.ToString().ToLowerInvariant())", + '/p:NETCORE_ENGINEERING_TELEMETRY=false' +) +$evaluationProperties = @("/p:ContinuousIntegrationBuild=$($CI.IsPresent.ToString().ToLowerInvariant())") +if ($Official) { + & (Join-Path $PSScriptRoot 'Validate-OfficialSource.ps1') -ReleaseBuild:$ReleaseBuild + $properties += @( + "/p:OfficialBuildId=$($env:BUILD_BUILDNUMBER)", + '/p:DotNetSignType=real', + "/p:TeamName=$($env:DOTNET_PACKAGE_SKILLS_SIGNING_TEAM)" + ) +} +else { $properties += '/p:OfficialBuildId=' } +if (-not [string]::IsNullOrWhiteSpace($env:BUILD_SOURCEVERSION)) { + if ($env:BUILD_SOURCEVERSION -cnotmatch '^[0-9a-f]{40}$') { throw 'Build.SourceVersion must be a full source commit SHA.' } + $properties += "/p:SourceRevisionId=$($env:BUILD_SOURCEVERSION)" +} +$evaluationProperties += $properties + +function Get-ArcadeDotnetPath { + $configuration = 'Release' + $prepareMachine = $false + $restore = $false + . (Join-Path $repoRoot 'eng\common\tools.ps1') + $installation = InitializeDotNetCli -install:$false + $dotnet = Join-Path $installation 'dotnet.exe' + if (-not (Test-Path -LiteralPath $dotnet -PathType Leaf)) { throw "Missing Arcade dotnet installation $dotnet." } + return $dotnet +} + +function Get-BuildMetadata { + $dotnet = Get-ArcadeDotnetPath + $arguments = @( + 'msbuild', (Join-Path $repoRoot 'dotnet-package-skills\src\DotnetPackageSkills.csproj'), + '-nologo', '-verbosity:quiet', '-target:GetDotnetPackageSkillsBuildMetadata', + '-property:Configuration=Release', '-property:TargetFramework=net8.0', + '-getProperty:Version,PackageVersion,DotnetPackageSkillsPackagePath,DotnetPackageSkillsBuildOutputPath,DotnetPackageSkillsStrongNameToolPath,ArtifactsTestResultsDir' + ) + $evaluationProperties + $metadata = (Invoke-CheckedCommand $dotnet $arguments | ConvertFrom-Json).Properties + if ($metadata.Version -cne $metadata.PackageVersion -or [string]::IsNullOrWhiteSpace($metadata.PackageVersion)) { + throw 'Arcade did not evaluate a consistent package and assembly version.' + } + $metadata | Add-Member -NotePropertyName DotnetPath -NotePropertyValue $dotnet + return $metadata +} + +function Invoke-ArcadeBuild { + param([string[]] $Actions, [string[]] $AdditionalProperties = @()) + + $arguments = @($Actions) + @('-configuration', 'Release') + $properties + $AdditionalProperties + if ($CI) { $arguments += '-ci' } + & (Join-Path $repoRoot 'eng\common\build.cmd') @arguments + if ($LASTEXITCODE -ne 0) { throw "Arcade $Action failed with exit code $LASTEXITCODE." } +} + +switch ($Action) { + 'RestoreBuild' { Invoke-ArcadeBuild @('-restore', '-build') } + 'Test' { + $start = [datetime]::UtcNow + Invoke-ArcadeBuild @('-test') @('/p:SkipTests=false', '/p:TestRunnerAdditionalArguments=') + $metadata = Get-BuildMetadata + & (Join-Path $PSScriptRoot 'Test-Results.ps1') -ResultsDirectory $metadata.ArtifactsTestResultsDir ` + -NotBeforeUtc $start -MinimumFunctionalTests 792 + } + 'Pack' { + $metadata = Get-BuildMetadata + $start = [datetime]::UtcNow + Invoke-ArcadeBuild @('-pack') @('/p:NoBuild=true') + $package = $metadata.DotnetPackageSkillsPackagePath + if (-not (Test-Path -LiteralPath $package -PathType Leaf) -or + (Get-Item -LiteralPath $package).Length -eq 0 -or + (Get-Item -LiteralPath $package).LastWriteTimeUtc -lt $start) { + throw "Arcade did not produce the exact current package $package." + } + $packages = @(Get-ChildItem -LiteralPath (Split-Path -Parent $package) -Filter 'dotnet-package-skills.*.nupkg') + if ($packages.Count -ne 1 -or $packages[0].FullName -cne $package) { + throw 'Unexpected dotnet-package-skills shipping package output; remove only the named stale tool packages before switching build kinds.' + } + } + 'Sign' { Invoke-ArcadeBuild @('-sign') } + 'Verify' { + $metadata = Get-BuildMetadata + $parameters = @{ + PackagePath = $metadata.DotnetPackageSkillsPackagePath + ExpectedVersion = $metadata.PackageVersion + BuildOutputPath = $metadata.DotnetPackageSkillsBuildOutputPath + DotnetPath = $metadata.DotnetPath + StrongNameToolPath = $metadata.DotnetPackageSkillsStrongNameToolPath + RequireSigned = $Official.IsPresent + } + if (-not [string]::IsNullOrWhiteSpace($env:BUILD_SOURCEVERSION)) { $parameters.ExpectedCommit = $env:BUILD_SOURCEVERSION } + & (Join-Path $PSScriptRoot 'Verify-Package.ps1') @parameters + if ($PackageArtifactPath -ne '') { + [IO.Directory]::CreateDirectory($PackageArtifactPath) | Out-Null + Copy-Item -LiteralPath $metadata.DotnetPackageSkillsPackagePath -Destination $PackageArtifactPath + } + if ($CI) { Write-Host '##vso[task.setvariable variable=DotnetPackageSkillsPackageVerified]true' } + } + 'Metadata' { Get-BuildMetadata | ConvertTo-Json } +} diff --git a/eng/pipelines/dotnet-package-skills/Package-Validation.psm1 b/eng/pipelines/dotnet-package-skills/Package-Validation.psm1 new file mode 100644 index 0000000..d99a985 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Package-Validation.psm1 @@ -0,0 +1,209 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-CheckedCommand { + param([string] $Command, [string[]] $Arguments) + + $output = & $Command @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "'$Command' failed with exit code ${LASTEXITCODE}:`n$($output -join "`n")" + } + return $output -join "`n" +} + +function Read-ArchiveText { + param([IO.Compression.ZipArchive] $Archive, [string] $Name) + + $entry = $Archive.GetEntry($Name) + if ($null -eq $entry) { throw "Package is missing $Name." } + if ($entry.Length -eq 0) { throw "Package contains empty $Name." } + $reader = [IO.StreamReader]::new($entry.Open()) + try { return $reader.ReadToEnd() } + finally { $reader.Dispose() } +} + +function Get-MetadataElement { + param([xml] $Nuspec, [string] $Name, [switch] $Optional) + + $nodes = @($Nuspec.SelectNodes("/*[local-name()='package']/*[local-name()='metadata']/*[local-name()='$Name']")) + if ($Optional -and $nodes.Count -eq 0) { return $null } + if ($nodes.Count -ne 1) { throw "Package metadata must contain one $Name element." } + return $nodes[0] +} + +function Test-DotnetPackageSkillsPackage { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string] $PackagePath, + [Parameter(Mandatory)][string] $ExpectedVersion, + [Parameter(Mandatory)][string] $BuildOutputPath, + [Parameter(Mandatory)][string] $DotnetPath, + [Parameter(Mandatory)][string] $StrongNameToolPath, + [string] $ExpectedCommit, + [switch] $RequireSigned + ) + + $package = (Resolve-Path -LiteralPath $PackagePath).Path + $buildOutput = (Resolve-Path -LiteralPath $BuildOutputPath).Path + if (-not (Test-Path -LiteralPath $StrongNameToolPath -PathType Leaf)) { + throw "Missing Arcade strong-name validation tool $StrongNameToolPath." + } + [xml] $policy = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'Payload.props') -Raw + $expectedPayload = [Collections.Generic.Dictionary[string, Xml.XmlElement]]::new([StringComparer]::Ordinal) + foreach ($item in $policy.Project.ItemGroup.FileSignInfo) { + $owner = $item.GetAttribute('Owner') + $certificate = $item.GetAttribute('CertificateName') + if (($owner -eq 'ThirdParty' -and $certificate -cne '3PartySHA2') -or + ($owner -in @('Microsoft', 'Tool') -and $certificate -cne 'MicrosoftDotNet500') -or + $owner -notin @('Microsoft', 'Tool', 'ThirdParty')) { + throw "Incorrect signing policy for $($item.GetAttribute('Include'))." + } + $cultures = @('') + if ($item.GetAttribute('Cultures') -ne '') { $cultures = $item.GetAttribute('Cultures').Split(';') } + foreach ($framework in $item.GetAttribute('Frameworks').Split(';')) { + foreach ($culture in $cultures) { + $relative = $item.GetAttribute('Include') + if ($culture -ne '') { $relative = "$culture/$relative" } + $expectedPayload.Add("tools/$framework/any/$relative", $item) + } + } + } + if ($expectedPayload.Count -eq 0) { throw 'The signing payload registry is empty.' } + + $scratch = [IO.Directory]::CreateTempSubdirectory('dotnet-package-skills-payload-').FullName + $archive = $null + try { + $archive = [IO.Compression.ZipFile]::OpenRead($package) + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $payload = [Collections.Generic.Dictionary[string, IO.Compression.ZipArchiveEntry]]::new([StringComparer]::Ordinal) + foreach ($entry in $archive.Entries) { + $name = $entry.FullName + if ([string]::IsNullOrWhiteSpace($name) -or [IO.Path]::IsPathRooted($name) -or + $name.Contains('\') -or $name.Contains(':') -or + @($name.Split('/') | Where-Object { $_ -in @('.', '..') }).Count -ne 0) { + throw "Invalid package archive path $name." + } + if (-not $seen.Add($name)) { throw "Package contains duplicate archive entry $name." } + if ($name.StartsWith('tools/', [StringComparison]::Ordinal) -and + $name -cnotmatch '^tools/(net8\.0|net10\.0)/any/') { + throw "Nonportable or unexpected tool payload $name." + } + $stream = $entry.Open() + try { $isPE = $stream.ReadByte() -eq 0x4d -and $stream.ReadByte() -eq 0x5a } + finally { $stream.Dispose() } + if ($isPE -or $name -match '\.(dll|exe)$') { + if (-not $expectedPayload.ContainsKey($name)) { throw "Unmapped package payload $name." } + if ($entry.Length -eq 0) { throw "Package contains empty payload $name." } + $payload.Add($name, $entry) + } + } + foreach ($name in $expectedPayload.Keys) { + if (-not $payload.ContainsKey($name)) { throw "Package is missing declared payload $name." } + } + + [xml] $nuspec = Read-ArchiveText $archive 'dotnet-package-skills.nuspec' + if ((Get-MetadataElement $nuspec 'id').InnerText -cne 'dotnet-package-skills') { + throw 'Package ID must be dotnet-package-skills.' + } + $version = (Get-MetadataElement $nuspec 'version').InnerText + if ($version -cne $ExpectedVersion) { throw "Package version '$version' does not match '$ExpectedVersion'." } + if ((Get-MetadataElement $nuspec 'authors').InnerText -cne 'dotnet-package-skills contributors') { + throw 'Package authors must remain dotnet-package-skills contributors.' + } + $types = @((Get-MetadataElement $nuspec 'packageTypes').ChildNodes | Where-Object LocalName -eq 'packageType') + if ($types.Count -ne 1 -or $types[0].GetAttribute('name') -cne 'DotnetTool') { + throw 'Package must declare only the DotnetTool package type.' + } + $license = Get-MetadataElement $nuspec 'license' + if ($license.GetAttribute('type') -cne 'expression' -or $license.InnerText -cne 'MIT') { + throw 'Package must retain its MIT license expression.' + } + $acceptance = Get-MetadataElement $nuspec 'requireLicenseAcceptance' -Optional + if ($null -ne $acceptance -and $acceptance.InnerText -cne 'false') { + throw 'Package license acceptance must remain false.' + } + $origin = Get-MetadataElement $nuspec 'repository' + if ($origin.GetAttribute('type') -cne 'git' -or + $origin.GetAttribute('url') -cne 'https://github.com/NuGet/Client.Tools' -or + $origin.GetAttribute('commit') -cnotmatch '^[0-9a-f]{40}$' -or + ($ExpectedCommit -ne '' -and $origin.GetAttribute('commit') -cne $ExpectedCommit)) { + throw 'Package origin must identify the expected NuGet/Client.Tools source commit.' + } + if ((Get-MetadataElement $nuspec 'readme').InnerText -cne 'README.md' -or + [string]::IsNullOrWhiteSpace((Read-ArchiveText $archive 'README.md'))) { + throw 'Package README must not be empty.' + } + $signature = $archive.GetEntry('.signature.p7s') + if ($RequireSigned -and $null -eq $signature) { + throw 'Package is not signed; official artifacts must have a NuGet signature.' + } + if (-not $RequireSigned -and $null -ne $signature) { throw 'Public and local packages must be unsigned.' } + if ($RequireSigned) { Write-Host (Invoke-CheckedCommand $DotnetPath @('nuget', 'verify', $package, '--all')) } + + foreach ($framework in @('net8.0', 'net10.0')) { + [xml] $settings = Read-ArchiveText $archive "tools/$framework/any/DotnetToolSettings.xml" + $commands = @($settings.DotNetCliTool.Commands.Command) + if ($commands.Count -ne 1 -or $commands[0].Name -cne 'dotnet-package-skills' -or + $commands[0].EntryPoint -cne 'dotnet-package-skills.dll' -or $commands[0].Runner -cne 'dotnet') { + throw "Incorrect tool command settings for $framework." + } + $runtime = Read-ArchiveText $archive "tools/$framework/any/dotnet-package-skills.runtimeconfig.json" | ConvertFrom-Json + if ($runtime.runtimeOptions.tfm -cne $framework -or + $runtime.runtimeOptions.framework.name -cne 'Microsoft.NETCore.App') { + throw "Incorrect runtime configuration for $framework." + } + $null = Read-ArchiveText $archive "tools/$framework/any/dotnet-package-skills.deps.json" | ConvertFrom-Json + } + + foreach ($name in $payload.Keys) { + $item = $expectedPayload[$name] + $parts = $name.Split('/') + $relative = $parts[3..($parts.Length - 1)] -join [IO.Path]::DirectorySeparatorChar + $original = Join-Path (Join-Path $buildOutput $parts[1]) $relative + if (-not (Test-Path -LiteralPath $original -PathType Leaf)) { throw "Missing built payload $original." } + $extracted = Join-Path $scratch ($name.Replace('/', [IO.Path]::DirectorySeparatorChar)) + [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($extracted)) | Out-Null + [IO.Compression.ZipFileExtensions]::ExtractToFile($payload[$name], $extracted) + $packageHash = (Get-FileHash -LiteralPath $extracted -Algorithm SHA256).Hash + $originalHash = (Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash + if ($item.GetAttribute('Owner') -eq 'Tool' -and $packageHash -cne $originalHash) { + throw "The $($parts[1]) package payload does not match the tested built assembly." + } + $identity = [Reflection.AssemblyName]::GetAssemblyName($extracted) + $originalIdentity = [Reflection.AssemblyName]::GetAssemblyName($original) + $token = [Convert]::ToHexString($identity.GetPublicKeyToken()).ToLowerInvariant() + if ($token -cne $item.GetAttribute('PublicKeyToken') -or + $identity.FullName -cne $originalIdentity.FullName -or + ($identity.GetPublicKey() -join ',') -cne ($originalIdentity.GetPublicKey() -join ',')) { + throw "Strong-name assembly identity changed for payload $name." + } + if ($token -ne '') { + $null = Invoke-CheckedCommand $StrongNameToolPath @('-q', '-vf', $original) + $null = Invoke-CheckedCommand $StrongNameToolPath @('-q', '-vf', $extracted) + } + if ($item.GetAttribute('OriginalSignature') -eq 'true') { + if ($packageHash -cne $originalHash) { throw "Original signed dependency changed for payload $name." } + if ((Get-AuthenticodeSignature -LiteralPath $original).Status -ne 'Valid') { + throw "Original dependency signature is not valid for $name." + } + } + if ($RequireSigned) { + $authenticode = Get-AuthenticodeSignature -LiteralPath $extracted + if ($authenticode.Status -ne 'Valid') { + throw "Extracted payload signature is not valid for ${name}: $($authenticode.StatusMessage)" + } + if ($item.GetAttribute('Owner') -in @('Microsoft', 'Tool') -and + $authenticode.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') { + throw "Extracted first-party payload has an unexpected signing identity: $name." + } + } + } + } + finally { + if ($null -ne $archive) { $archive.Dispose() } + Remove-Item -LiteralPath $scratch -Recurse -Force + } + Write-Host "Verified package metadata, $($expectedPayload.Count) PE payloads, original signatures, and strong-name identities for $ExpectedVersion." +} + +Export-ModuleMember -Function Invoke-CheckedCommand, Test-DotnetPackageSkillsPackage diff --git a/eng/pipelines/dotnet-package-skills/Payload.props b/eng/pipelines/dotnet-package-skills/Payload.props new file mode 100644 index 0000000..8d08301 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Payload.props @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/eng/pipelines/dotnet-package-skills/README.md b/eng/pipelines/dotnet-package-skills/README.md new file mode 100644 index 0000000..5d6e322 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/README.md @@ -0,0 +1,214 @@ +# dotnet-package-skills pipeline + +`stage.yml` contributes one authored `DotnetPackageSkills` stage. The tool's pipeline code +lives in this folder. The root Arcade graph includes the tool solution and +`eng\Infrastructure.proj`. The stage runs that infrastructure check as part of its build. +It does not add a second smoke job. There are no Python, Node, Linux, or macOS CI workloads. + +| Entry point | Source | Output | +| --- | --- | --- | +| `eng\pipelines\pr.yml` | GitHub, PRs targeting `main`, in `dnceng-public/public` | Unsigned package, both framework results, logs | +| `eng\pipelines\official.yml` | Trusted `dnceng/internal/NuGet-Client.Tools` Azure Repos mirror, `main`, non-PR | Real-signed package and 1ES-governed artifacts | + +The main-only PR filter belongs to the shared foundation. Keep it. A PR stacked above the +onboarding branch might not trigger Azure Pipelines. Queue public definition 351, +`client.tools-ci`, for the exact pushed upper commit when needed. A successful infrastructure +run for the lower PR does not validate the tool. + +## Build and artifact contract + +Run every build action from the repository root through `eng\common\build.cmd`. +The root `global.json`, `NuGet.Config`, and `eng\Versions.props` are shared inputs. +The tool imports the root Arcade props and keeps its scoped compiler settings. +Do not add a tool-local SDK pin or restore configuration. +The root MIT file uses Arcade's required heading and reserved-rights line. Its NuGet copyright +and MIT permission grant stay unchanged; repository license validation remains enabled. + +The tool keeps `SignAssembly=false`, `IsPackable=true`, and `IsShipping=true`. +Tests stay unsigned, non-packable, and non-shipping. The infrastructure project stays +non-packable and non-shipping. Keep the package and command name `dotnet-package-skills`, +the existing authors, MIT license, and false license-acceptance setting. Keep both +`tools/net8.0/any` and `tools/net10.0/any`. Do not set a runtime identifier. + +| Content | Native path | +| --- | --- | +| Tool binaries | `artifacts\bin\DotnetPackageSkills\Release\` | +| Intermediate files | `artifacts\obj\DotnetPackageSkills` | +| Build and signing logs | `artifacts\log\Release` | +| Both VSTest result files | `artifacts\TestResults\Release` | +| Shipping package | `artifacts\packages\Release\Shipping` | + +The pipeline installs the root-pinned SDK and the .NET 8 runtime into the same `.dotnet` +installation. It sets `DOTNET_INSTALL_DIR` so Arcade selects that installation. Local +verification uses Arcade's own `InitializeDotNetCli` selection. A runtime installed beside +an unrelated PATH `dotnet` does not satisfy this contract. + +The order is: + +1. Restore and build the root graph. +2. Run C# tests on both frameworks. +3. Pack without rebuilding. +4. Validate the signing inputs. Official builds recursively sign the innermost DLLs, + repack the package, and sign its NuGet container. Public builds use a dry run. +5. Verify the final package and install that exact package on each runtime. +6. Publish the verified package. Keep logs and results on failure. + +Do not build or pack after signing. There is no intermediate `obj` signing workaround. +Arcade's VSTest runner executes the xUnit suite. Supported version properties retain xUnit +2.9.3, its original analyzer version, VS runner 3.1.4, and Test SDK 17.14.1. Coverlet stays +6.0.4. Test publication fails on failed or missing files. The separate results check also +requires fresh, complete, successful files for both frameworks and preserves the 792 +application tests that remain after retiring the 15 custom-version-script cases. + +## Native versions + +`eng\Versions.props` holds the reviewed `VersionPrefix=0.1.0` and +`PreReleaseVersionLabel=beta`. Arcade computes the versions. There is no custom date or +package-version calculator. + +| Build kind | Package version | +| --- | --- | +| Local | `0.1.0-dev` | +| Public CI or PR | `0.1.0-ci` | +| Ordinary official | `0.1.0-beta..` | +| Explicit manual release from trusted main | `0.1.0` | + +The fixed public CI suffix is deliberate. These packages are artifacts, not immutable feed +publications. The official pipeline name is `$(Date:yyyyMMdd).$(Rev:r)`. Pass that build number +as `OfficialBuildId`, not Azure's numeric `Build.BuildId`. The guard requires a real date and +a revision from 1 through 99 without leading zeros. Use the same inputs for build, test, pack, +sign, and verification. + +`DotnetPackageSkillsReleaseBuild` defaults to false. After reviewing the base version and +confirming trusted-mirror parity, manually queue the official main definition with this option +set to true. Only this request maps to `DotNetFinalVersionKind=release`. Public, PR, automatic, +non-main, missing, and malformed release requests fail. A direct final-kind or package-version +override cannot bypass the guard. Builds do not create version commits, tags, or releases. + +`Invoke-Build.ps1 -Action Metadata` runs the native MSBuild evaluation with matching inputs. +The wrapper uses the evaluated `Version`, `PackageVersion`, package path, binary path, and +strong-name tool path. It never selects a package by a stale glob. Pack also rejects an +unexpected second tool package. Before changing build kinds locally, remove only the +specific old tool package that you generated, not a broad artifacts directory. + +## Arcade signing and owner prerequisites + +The reference is how +[dotnet/sign builds its own releases](https://github.com/dotnet/sign/blob/f3cc758ffd4f5edccb567820606b17eea49745c0/.vsts-ci.yml), +not the public `sign` CLI as a production signer. The official job uses Arcade's +`templates-official` wrapper with `enableMicrobuild=true`, `_SignType=real`, and +`DotNetSignType=real`. The inherited `MicroBuildSigningPlugin@4` and ESRP integration supply +the DNCEng shared feed and connections. Do not replace them with direct `EsrpCodeSigning@6` +steps, invent connection values, or create credentials. + +The stage includes signing resources only when template expansion identifies all of these: +TfsGit, repository `NuGet-Client.Tools`, project `internal`, collection +`https://dev.azure.com/dnceng/`, main, and a nonempty non-PR reason. Any other official source +expands to a rejecting 1ES job with `runAsPublic=true` and MicroBuild disabled. A runtime +source guard repeats the checks before plugin installation. The public entry point has no +1ES repository or production signing connection. + +The owner must configure `DotnetPackageSkillsSigningTeamName` with the approved team +identifier. The job maps this one non-secret setting to `_TeamName` and `TeamName`. +Missing or unresolved values fail. The old eight custom ESRP settings are retired. + +Before a real-signing run, the owner must confirm: + +1. The internal definition uses the trusted mirror and has the reviewed source commit. +2. The internal pool and 1ES template repository are authorized. +3. The MicroBuild v4 extension, MicroBuildToolset feed, inherited shared connections, team, + and signing profiles are authorized for this definition. +4. The required signing approvals and branch controls are satisfied. + +Do not authorize every pipeline as a shortcut. Do not grant permissions as part of this +migration. Extension installation alone does not prove connection or profile authorization. +The first successful authorized official run remains an acceptance gate. + +### Signing registry and payload policy + +Root `eng\Signing.props` is a thin registry. It imports this folder's signing configuration. +The configuration removes the broad automatic artifact list and evaluates exactly three +inputs: this build's shipping `.nupkg` and the tested loose owned DLL for each framework. +Missing, empty, or unmapped inputs fail. + +`Payload.props` is the reviewed inventory. It covers all 33 shipped PE entries: + +| DLL | Ownership and policy | +| --- | --- | +| `dotnet-package-skills.dll` | Tool-owned, `MicrosoftDotNet500`, still not strong-named | +| `SharpYaml.dll` | Third-party, `3PartySHA2`; retain its existing strong name | +| `System.Collections.Immutable.dll` | .NET, net8.0 only; retain its original Microsoft signature and strong name | +| `System.CommandLine.dll` | Microsoft; retain its original signature and strong name | +| `System.CommandLine.resources.dll` | All 13 shipped cultures on both TFMs; retain their original signatures and strong names | +| Outer `.nupkg` | Standard `NuGet` profile | + +The dependency versions stay SharpYaml 3.13.1 and System.CommandLine 2.0.10. The +net8.0 payload also contains System.Collections.Immutable 9.0.0. Do not assign a Microsoft +product certificate to a third-party DLL. Do not suppress the third-party checks or add +blanket ignore rules. No new strong-name key is assigned. + +The pinned SignTool deduplicates identical content and updates the explicitly listed loose +copies. This retains the strict SHA256 comparison between each packaged owned DLL and its +tested loose copy. The preflight and final verifier check redistributed full assembly/key +identities and validate their strong-name signatures with Arcade's `sn.exe`. Existing +Microsoft dependency signatures must remain byte-for-byte unchanged. + +`Verify-Package.ps1 -RequireSigned` checks the actual NuGet signature with +`dotnet nuget verify --all` and validates every extracted owned and dependency DLL signature. +It checks identity, version, MIT, authors, origin commit, README, tool settings, portable +layout, dependency inventory, and owned DLL hashes. It rejects duplicate or unsafe ZIP paths. +The isolated installation uses only the exact package in a local-only feed, separate NuGet +cache and CLI home, and separate tool paths. It runs `--version`, `--help`, `list`, `install`, +and `uninstall` on each runtime and preserves a handwritten fixture skill. + +A successful dry run proves the signing plan, not production signatures or authorization. + +## Local commands and artifacts + +From the repository root: + +```powershell +eng\common\build.cmd -restore -build -test -configuration Release +eng\common\build.cmd -pack -configuration Release /p:NoBuild=true +eng\common\build.cmd -sign -configuration Release /p:NETCORE_ENGINEERING_TELEMETRY=false +eng\pipelines\dotnet-package-skills\Invoke-Build.ps1 -Action Metadata +eng\pipelines\dotnet-package-skills\Invoke-Build.ps1 -Action Verify +``` + +To run the same public-CI actions locally, pass `-CI` to each wrapper action: +`RestoreBuild`, `Test`, `Pack`, `Sign`, then `Verify`. Do not mix local and CI inputs. +Never use `-prepareMachine` on a shared local machine. Do not run broad clean commands. + +Artifacts are named `dotnet-package-skills-packages`, `dotnet-package-skills-testresults`, +and `dotnet-package-skills-logs`. The wrapper stages only the verified exact package under +`$(Build.ArtifactStagingDirectory)\dotnet-package-skills\packages`. Official outputs use 1ES +`templateContext.outputs`; public outputs use ordinary artifact tasks. The shared Arcade +wrapper also retains its diagnostic log artifact. No feeds, BAR/Maestro assets, symbols, +GitHub releases, or tags are published. + +The C# tests expand the owned templates for public, trusted, and rejected source cases. +They do not substitute for Azure's server expansion or the authorized official 1ES run. + +## Retirement when the tool moves into the .NET SDK + +Perform this work in a separate reviewed change. Validate removal in an isolated fixture +first, not by deleting the active product checkout: + +1. Remove the tool solution from `eng\Build.props`. Keep `eng\Infrastructure.proj` and any + other tool entries. +2. Remove this module's import from `eng\Versions.props` and `eng\Signing.props`. Keep the + reviewed base-version properties and other signing modules. If the signing registry is + empty, remove that registry file. +3. Remove the stage-template references and `DotnetPackageSkillsReleaseBuild` parameter + from the root entry points. Retain shared pools, main-only triggers, and the 1ES wrapper. + If this is the only workload, replace its reference with infrastructure validation or + another active workload. Do not leave an invalid empty stage list. +4. Remove the `dotnet-package-skills` source folder and this pipeline folder. Remove the + tool's root README entry. Do not restore old nested sources, samples, or deleted docs. +5. Run the root Arcade restore/build and check that the infrastructure and other tools + remain valid. Keep root SDK/feed files, Arcade imports, and `eng\common` unchanged. +6. Have the owner retire tool-specific definition settings and authorizations where + appropriate. Keep shared pools, connections, the trusted mirror, and 1ES governance. + +The retirement fixture checks both the infrastructure project and a separate future-tool +entry so removing this module cannot remove the shared foundation. diff --git a/eng/pipelines/dotnet-package-skills/Signing.props b/eng/pipelines/dotnet-package-skills/Signing.props new file mode 100644 index 0000000..24965e0 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Signing.props @@ -0,0 +1,41 @@ + + + true + false + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/eng/pipelines/dotnet-package-skills/Test-Package.ps1 b/eng/pipelines/dotnet-package-skills/Test-Package.ps1 new file mode 100644 index 0000000..2186eeb --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Test-Package.ps1 @@ -0,0 +1,15 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $PackagePath, + [Parameter(Mandatory)][string] $ExpectedVersion, + [Parameter(Mandatory)][string] $BuildOutputPath, + [Parameter(Mandatory)][string] $DotnetPath, + [Parameter(Mandatory)][string] $StrongNameToolPath, + [string] $ExpectedCommit, + [switch] $RequireSigned +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Import-Module (Join-Path $PSScriptRoot 'Package-Validation.psm1') -Force +Test-DotnetPackageSkillsPackage @PSBoundParameters diff --git a/eng/pipelines/dotnet-package-skills/Test-Results.ps1 b/eng/pipelines/dotnet-package-skills/Test-Results.ps1 new file mode 100644 index 0000000..f6388eb --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Test-Results.ps1 @@ -0,0 +1,51 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $ResultsDirectory, + [datetime] $NotBeforeUtc = [datetime]::MinValue, + [ValidateRange(0, [int]::MaxValue)][int] $MinimumFunctionalTests = 0 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +foreach ($framework in @('net8.0', 'net10.0')) { + $path = Join-Path $ResultsDirectory "DotnetPackageSkills.Tests_${framework}_x64.trx" + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing required $framework test results: $path." } + if ((Get-Item -LiteralPath $path).LastWriteTimeUtc -lt $NotBeforeUtc.ToUniversalTime()) { + throw "Stale $framework test results cannot validate the current run." + } + [xml] $results = Get-Content -LiteralPath $path -Raw + $summary = $results.SelectSingleNode("/*[local-name()='TestRun']/*[local-name()='ResultSummary']") + if ($null -eq $summary) { throw "Missing $framework results summary." } + $counters = $summary.SelectSingleNode("*[local-name()='Counters']") + if ($null -eq $counters) { throw "Missing $framework results counters." } + $counts = @{} + foreach ($name in @('total', 'executed', 'passed', 'failed')) { + $value = $counters.GetAttribute($name) + if ($value -cnotmatch '^[0-9]+$') { throw "Malformed $framework results counter $name." } + $counts[$name] = [long]::Parse($value, [Globalization.CultureInfo]::InvariantCulture) + } + foreach ($attribute in $counters.Attributes) { + if ($attribute.Name -in @('error', 'timeout', 'aborted', 'inconclusive', 'notRunnable', 'notExecuted', 'disconnected', 'pending', 'inProgress') -and + $attribute.Value -cne '0') { + throw "Unsuccessful $framework results counter $($attribute.Name)." + } + } + $tests = @($results.SelectNodes("/*[local-name()='TestRun']/*[local-name()='Results']/*[local-name()='UnitTestResult']")) + if ($summary.GetAttribute('outcome') -cne 'Completed' -or + $counts.total -le 0 -or $counts.executed -ne $counts.total -or + $counts.passed -ne $counts.total -or $counts.failed -ne 0 -or + $tests.Count -ne $counts.total -or + @($tests | Where-Object { $_.GetAttribute('outcome') -cne 'Passed' }).Count -ne 0) { + throw "Empty, failed, or incomplete $framework test results." + } + $functional = @($tests | Where-Object { + $name = $_.GetAttribute('testName') + $name.StartsWith('DotnetPackageSkills.Tests.', [StringComparison]::Ordinal) -and + -not $name.StartsWith('DotnetPackageSkills.Tests.Pipeline', [StringComparison]::Ordinal) + }).Count + if ($functional -lt $MinimumFunctionalTests) { + throw "The $framework results contain $functional functional tests; at least $MinimumFunctionalTests are required." + } + Write-Host "$framework results: $($counts.passed) passed, $functional functional tests." +} diff --git a/eng/pipelines/dotnet-package-skills/Test-Retirement.ps1 b/eng/pipelines/dotnet-package-skills/Test-Retirement.ps1 new file mode 100644 index 0000000..57743c0 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Test-Retirement.ps1 @@ -0,0 +1,153 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$source = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..\..')).Path +$fixture = [IO.Directory]::CreateTempSubdirectory('client-tools-retirement-fixture-').FullName +$originalLocation = Get-Location + +try { + foreach ($file in @('global.json', 'NuGet.Config', 'Directory.Build.props', 'Directory.Build.targets', 'build.cmd', 'LICENSE')) { + Copy-Item -LiteralPath (Join-Path $source $file) -Destination $fixture + } + Copy-Item -LiteralPath (Join-Path $source 'eng') -Destination $fixture -Recurse + Copy-Item -LiteralPath (Join-Path $source 'dotnet-package-skills') -Destination $fixture -Recurse + $future = Join-Path $fixture 'eng\FutureTool.proj' + [IO.File]::WriteAllText($future, @' + + + net10.0 + false + false + + + + + + +'@) + $futureSigning = Join-Path $fixture 'eng\pipelines\future-tool' + [IO.Directory]::CreateDirectory($futureSigning) | Out-Null + [IO.File]::WriteAllText((Join-Path $futureSigning 'Signing.props'), '') + + [xml] $build = Get-Content -LiteralPath (Join-Path $fixture 'eng\Build.props') -Raw + $entry = $build.CreateElement('ProjectToBuild') + $entry.SetAttribute('Include', '$(RepoRoot)eng\FutureTool.proj') + $null = $build.Project.ItemGroup.AppendChild($entry) + $build.Save((Join-Path $fixture 'eng\Build.props')) + [xml] $signing = Get-Content -LiteralPath (Join-Path $fixture 'eng\Signing.props') -Raw + $entry = $signing.CreateElement('Import') + $entry.SetAttribute('Project', 'pipelines\future-tool\Signing.props') + $null = $signing.Project.AppendChild($entry) + $signing.Save((Join-Path $fixture 'eng\Signing.props')) + + foreach ($file in @('Build.props', 'Versions.props', 'Signing.props')) { + $path = Join-Path $fixture "eng\$file" + [xml] $document = Get-Content -LiteralPath $path -Raw + foreach ($element in @($document.SelectNodes('//*[@Include or @Project]'))) { + if ($element.GetAttribute('Include').Contains('dotnet-package-skills') -or + $element.GetAttribute('Project').Contains('dotnet-package-skills')) { + $null = $element.ParentNode.RemoveChild($element) + } + } + $document.Save($path) + if ($document.OuterXml.Contains('dotnet-package-skills')) { throw "Retirement left a tool reference in $file." } + } + [xml] $versions = Get-Content -LiteralPath (Join-Path $fixture 'eng\Versions.props') -Raw + if ($versions.Project.PropertyGroup.VersionPrefix -cne '0.1.0' -or + $versions.Project.PropertyGroup.PreReleaseVersionLabel -cne 'beta') { + throw 'Retirement changed the shared base-version properties.' + } + [xml] $build = Get-Content -LiteralPath (Join-Path $fixture 'eng\Build.props') -Raw + $projects = @($build.Project.ItemGroup.ProjectToBuild | ForEach-Object { $_.GetAttribute('Include') }) + if ($projects -notcontains '$(RepoRoot)eng\Infrastructure.proj' -or + $projects -notcontains '$(RepoRoot)eng\FutureTool.proj') { + throw 'Retirement removed infrastructure or another tool from the root graph.' + } + if (-not ([IO.File]::ReadAllText((Join-Path $fixture 'eng\Signing.props'))).Contains('future-tool\Signing.props')) { + throw 'Retirement removed another signing module.' + } + + $publicPath = Join-Path $fixture 'eng\pipelines\pr.yml' + $public = [IO.File]::ReadAllText($publicPath).Replace("`r`n", "`n") + $public = $public.Replace('- template: /eng/pipelines/dotnet-package-skills/stage.yml', @' +- stage: ArcadeInfrastructure + jobs: + - template: /eng/common/templates/jobs/jobs.yml + parameters: + runAsPublic: true + enableMicrobuild: false + enableTelemetry: false + enablePublishBuildAssets: false + jobs: + - job: Infrastructure + steps: + - script: eng\common\build.cmd -restore -build -configuration Release -ci +'@) + [IO.File]::WriteAllText($publicPath, $public) + $officialPath = Join-Path $fixture 'eng\pipelines\official.yml' + $official = [IO.File]::ReadAllText($officialPath).Replace("`r`n", "`n") + $removedParameters = @' +parameters: +- name: DotnetPackageSkillsReleaseBuild + type: boolean + default: false + +'@ + $official = $official.Replace($removedParameters.Replace("`r`n", "`n"), '') + $removedStage = @' + - template: /eng/pipelines/dotnet-package-skills/stage.yml@self + parameters: + isOfficialBuild: true + releaseBuild: ${{ parameters.DotnetPackageSkillsReleaseBuild }} +'@ + $infrastructureStage = @' + - stage: ArcadeInfrastructure + jobs: + - template: /eng/common/templates-official/jobs/jobs.yml@self + parameters: + enableMicrobuild: false + enableTelemetry: false + enablePublishBuildAssets: false + jobs: + - job: Infrastructure + steps: + - script: eng\common\build.cmd -restore -build -configuration Release -ci +'@ + $official = $official.Replace($removedStage.Replace("`r`n", "`n"), $infrastructureStage.Replace("`r`n", "`n")) + [IO.File]::WriteAllText($officialPath, $official) + foreach ($path in @($publicPath, $officialPath)) { + if ([IO.File]::ReadAllText($path) -match 'dotnet-package-skills|DotnetPackageSkillsReleaseBuild') { + throw "Retirement left a tool reference in $path." + } + } + if ($official -notmatch 'v1/1ES\.Official\.PipelineTemplate\.yml@1esPipelines') { + throw 'Retirement removed 1ES governance.' + } + + Remove-Item -LiteralPath (Join-Path $fixture 'dotnet-package-skills') -Recurse -Force + Remove-Item -LiteralPath (Join-Path $fixture 'eng\pipelines\dotnet-package-skills') -Recurse -Force + foreach ($file in @('global.json', 'NuGet.Config', 'Directory.Build.props', 'Directory.Build.targets', 'LICENSE')) { + if ((Get-FileHash -LiteralPath (Join-Path $source $file)).Hash -cne + (Get-FileHash -LiteralPath (Join-Path $fixture $file)).Hash) { + throw "Retirement changed the shared foundation file $file." + } + } + foreach ($file in Get-ChildItem -LiteralPath (Join-Path $source 'eng\common') -Recurse -File) { + $relative = [IO.Path]::GetRelativePath($source, $file.FullName) + if ((Get-FileHash -LiteralPath $file.FullName).Hash -cne + (Get-FileHash -LiteralPath (Join-Path $fixture $relative)).Hash) { + throw "Retirement changed vendored Arcade $relative." + } + } + Set-Location -LiteralPath $fixture + & (Join-Path $fixture 'eng\common\build.cmd') -restore -build -configuration Release -ci /p:NETCORE_ENGINEERING_TELEMETRY=false + if ($LASTEXITCODE -ne 0) { throw 'The retired fixture could not restore and build the shared Arcade graph.' } + Write-Host 'Isolated retirement validation succeeded; shared infrastructure and future tool references are intact.' +} +finally { + Set-Location -LiteralPath $originalLocation + Remove-Item -LiteralPath $fixture -Recurse -Force +} diff --git a/eng/pipelines/dotnet-package-skills/Validate-OfficialSource.ps1 b/eng/pipelines/dotnet-package-skills/Validate-OfficialSource.ps1 new file mode 100644 index 0000000..e7ff689 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Validate-OfficialSource.ps1 @@ -0,0 +1,22 @@ +[CmdletBinding()] +param([switch] $ReleaseBuild) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if ($env:BUILD_REPOSITORY_PROVIDER -cne 'TfsGit' -or + $env:BUILD_REPOSITORY_NAME -cne 'NuGet-Client.Tools' -or + $env:SYSTEM_TEAMPROJECT -cne 'internal' -or + $env:SYSTEM_COLLECTIONURI -cne 'https://dev.azure.com/dnceng/' -or + $env:BUILD_SOURCEBRANCH -cne 'refs/heads/main' -or + [string]::IsNullOrWhiteSpace($env:BUILD_REASON) -or $env:BUILD_REASON -eq 'PullRequest') { + throw 'Official signing requires main from the trusted dnceng/internal/NuGet-Client.Tools repository, with non-PR source metadata.' +} +if ($ReleaseBuild -and $env:BUILD_REASON -cne 'Manual') { + throw 'DotnetPackageSkillsReleaseBuild requires a manual official build on trusted main.' +} +$team = $env:DOTNET_PACKAGE_SKILLS_SIGNING_TEAM +if ([string]::IsNullOrWhiteSpace($team) -or $team -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$') { + throw 'Configure the owner-approved DotnetPackageSkillsSigningTeamName on the official definition; a missing or unresolved signing team is not allowed.' +} +Write-Host 'Trusted source and configured signing team validated. Signing resource authorization remains an owner prerequisite.' diff --git a/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 b/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 new file mode 100644 index 0000000..15c6e63 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 @@ -0,0 +1,132 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PackagePath, + + [Parameter(Mandatory)] + [string] $ExpectedVersion, + + [Parameter(Mandatory)] + [string] $BuildOutputPath, + + [Parameter(Mandatory)] + [string] $DotnetPath, + + [Parameter(Mandatory)] + [string] $StrongNameToolPath, + + [string] $ExpectedCommit, + + [switch] $RequireSigned +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +Import-Module (Join-Path $PSScriptRoot 'Package-Validation.psm1') -Force +Test-DotnetPackageSkillsPackage @PSBoundParameters +$package = (Resolve-Path -LiteralPath $PackagePath).Path +$DotnetPath = (Resolve-Path -LiteralPath $DotnetPath).Path +$runtimes = Invoke-CheckedCommand $DotnetPath @('--list-runtimes') +foreach ($major in @('8', '10')) { + if ($runtimes -notmatch "(?m)^Microsoft\.NETCore\.App $major\.") { + throw "The Arcade dotnet installation is missing the .NET $major runtime." + } +} + +$temporary = [IO.Directory]::CreateTempSubdirectory('dotnet-package-skills-verify-').FullName +$originalEnvironment = @{} +foreach ($name in @( + 'NUGET_PACKAGES', 'DOTNET_CLI_HOME', 'DOTNET_ROLL_FORWARD', + 'DOTNET_GENERATE_ASPNET_CERTIFICATE', 'DOTNET_ADD_GLOBAL_TOOLS_TO_PATH', + 'DOTNET_CLI_TELEMETRY_OPTOUT', 'DOTNET_NOLOGO', 'DOTNET_ROOT', 'DOTNET_ROOT_X64', + 'DOTNET_MULTILEVEL_LOOKUP' +)) { + $originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) +} + +try { + $feed = [IO.Directory]::CreateDirectory((Join-Path $temporary 'feed')).FullName + Copy-Item -LiteralPath $package -Destination $feed + $config = Join-Path $temporary 'NuGet.config' + $escapedFeed = [Security.SecurityElement]::Escape($feed) + [IO.File]::WriteAllText($config, @" + + + + + +"@) + $env:NUGET_PACKAGES = Join-Path $temporary 'nuget-cache' + $env:DOTNET_CLI_HOME = Join-Path $temporary 'cli-home' + $env:DOTNET_ROLL_FORWARD = 'LatestPatch' + $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = 'false' + $env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = 'false' + $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' + $env:DOTNET_NOLOGO = '1' + $env:DOTNET_ROOT = Split-Path -Parent $DotnetPath + $env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT + $env:DOTNET_MULTILEVEL_LOOKUP = '0' + + $fixtureCache = Join-Path $temporary 'fixture-cache' + $skill = Join-Path $fixtureCache 'contoso.widgets\2.3.0\skills\contoso.widgets-widget-usage' + [IO.Directory]::CreateDirectory($skill) | Out-Null + [IO.File]::WriteAllText((Join-Path $skill 'SKILL.md'), "---`nname: contoso.widgets-widget-usage`ndescription: Local package verification fixture.`n---`n") + + foreach ($framework in @('net8.0', 'net10.0')) { + $toolPath = Join-Path $temporary "tools-$framework" + Write-Host (Invoke-CheckedCommand $DotnetPath @( + 'tool', 'install', 'dotnet-package-skills', '--tool-path', $toolPath, + '--version', $ExpectedVersion, '--framework', $framework, + '--configfile', $config, '--no-http-cache', '--verbosity', 'quiet' + )) + + $tool = Join-Path $toolPath 'dotnet-package-skills.exe' + $version = (Invoke-CheckedCommand $tool @('--version')).Trim() + if (($version -split '\+', 2)[0] -cne $ExpectedVersion) { + throw "Installed $framework tool version '$version' does not match '$ExpectedVersion'." + } + $help = Invoke-CheckedCommand $tool @('--help') + if ($help -notmatch 'install' -or $help -notmatch 'uninstall') { + throw "Installed $framework tool does not expose its expected commands." + } + + $destination = Join-Path $temporary "skills-$framework" + $handwritten = Join-Path $destination 'handwritten' + [IO.Directory]::CreateDirectory($handwritten) | Out-Null + [IO.File]::WriteAllText((Join-Path $handwritten 'SKILL.md'), 'Preserve this skill.') + $packageArguments = @('--package', 'Contoso.Widgets@2.3.0', '--global-packages', $fixtureCache, '--destination', $destination) + + $listing = Invoke-CheckedCommand $tool (@('list') + $packageArguments) + if ($listing -notmatch 'contoso.widgets-widget-usage') { + throw "Installed $framework tool did not discover the local fixture." + } + Write-Host (Invoke-CheckedCommand $tool (@('install') + $packageArguments)) + $installedSkill = Join-Path $destination 'contoso.widgets-widget-usage\SKILL.md' + if (-not (Test-Path -LiteralPath $installedSkill)) { + throw "Installed $framework tool did not copy the fixture skill." + } + $manifestPath = Join-Path $destination '.dotnet-package-skills.json' + $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + if ($manifest.packages.'contoso.widgets'.version -cne '2.3.0') { + throw "Installed $framework tool did not record the fixture package version." + } + Write-Host (Invoke-CheckedCommand $tool @('uninstall', '--package', 'Contoso.Widgets', '--destination', $destination)) + if (Test-Path -LiteralPath $installedSkill) { + throw "Installed $framework tool did not remove the fixture skill." + } + if ([IO.File]::ReadAllText((Join-Path $handwritten 'SKILL.md')) -cne 'Preserve this skill.') { + throw "Installed $framework tool changed a handwritten skill." + } + } +} +finally { + foreach ($name in $originalEnvironment.Keys) { + $value = $originalEnvironment[$name] + if ($null -eq $value) { $value = [NullString]::Value } + [Environment]::SetEnvironmentVariable($name, $value) + } + Remove-Item -LiteralPath $temporary -Recurse -Force +} + +Write-Host "Verified dotnet-package-skills $ExpectedVersion on .NET 8 and .NET 10." diff --git a/eng/pipelines/dotnet-package-skills/jobs-build.yml b/eng/pipelines/dotnet-package-skills/jobs-build.yml new file mode 100644 index 0000000..ffa0432 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/jobs-build.yml @@ -0,0 +1,92 @@ +parameters: +- name: isOfficialBuild + type: boolean + default: false +- name: releaseBuild + type: boolean + default: false +- name: jobsTemplate + type: string + default: /eng/common/templates/jobs/jobs.yml + +jobs: +- template: ${{ parameters.jobsTemplate }} + parameters: + runAsPublic: ${{ not(parameters.isOfficialBuild) }} + enableMicrobuild: ${{ parameters.isOfficialBuild }} + microbuildUseESRP: true + enableMicrobuildForMacAndLinux: false + enableTelemetry: false + enablePublishBuildAssets: false + enablePublishing: false + enablePublishBuildArtifacts: false + enablePublishTestResults: false + artifacts: + publish: + logs: true + jobs: + - job: DotnetPackageSkillsWindows + displayName: Build, test, pack, and verify dotnet-package-skills + timeoutInMinutes: 60 + variables: + - name: _BuildConfig + value: Release + - name: DotnetPackageSkillsPackageVerified + value: 'false' + - name: DotnetPackageSkillsPipelineDirectory + value: $(Build.SourcesDirectory)\eng\pipelines\dotnet-package-skills + - name: DotnetPackageSkillsPackageArtifactPath + value: $(Build.ArtifactStagingDirectory)\dotnet-package-skills\packages + - name: DOTNET_INSTALL_DIR + value: $(Build.SourcesDirectory)\.dotnet + - name: DOTNET_ROOT + value: $(Build.SourcesDirectory)\.dotnet + - name: DOTNET_ROOT_X64 + value: $(Build.SourcesDirectory)\.dotnet + - name: DOTNET_NOLOGO + value: '1' + - name: DOTNET_CLI_TELEMETRY_OPTOUT + value: '1' + - name: DOTNET_GENERATE_ASPNET_CERTIFICATE + value: 'false' + - name: DOTNET_ADD_GLOBAL_TOOLS_TO_PATH + value: 'false' + - ${{ if eq(parameters.isOfficialBuild, true) }}: + - name: _SignType + value: real + - name: _TeamName + value: $(DotnetPackageSkillsSigningTeamName) + - name: DOTNET_PACKAGE_SKILLS_SIGNING_TEAM + value: $(DotnetPackageSkillsSigningTeamName) + ${{ if eq(parameters.isOfficialBuild, true) }}: + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(DotnetPackageSkillsPackageArtifactPath) + artifactName: dotnet-package-skills-packages + condition: and(succeeded(), eq(variables['DotnetPackageSkillsPackageVerified'], 'true')) + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)\artifacts\log\Release + artifactName: dotnet-package-skills-logs + condition: succeededOrFailed() + isProduction: false + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)\artifacts\TestResults\Release + artifactName: dotnet-package-skills-testresults + condition: succeededOrFailed() + isProduction: false + preSteps: + - checkout: self + clean: true + fetchDepth: 0 + fetchTags: false + - ${{ if eq(parameters.isOfficialBuild, true) }}: + - pwsh: | + & "$(DotnetPackageSkillsPipelineDirectory)\Validate-OfficialSource.ps1" ` + -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) + displayName: Validate trusted source and approved signing team before MicroBuild + steps: + - template: steps-build.yml + parameters: + isOfficialBuild: ${{ parameters.isOfficialBuild }} + releaseBuild: ${{ parameters.releaseBuild }} diff --git a/eng/pipelines/dotnet-package-skills/stage.yml b/eng/pipelines/dotnet-package-skills/stage.yml new file mode 100644 index 0000000..39f9676 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/stage.yml @@ -0,0 +1,39 @@ +parameters: +- name: isOfficialBuild + type: boolean + default: false +- name: releaseBuild + type: boolean + default: false + +stages: +- stage: DotnetPackageSkills + displayName: dotnet-package-skills + dependsOn: [] + jobs: + - ${{ if eq(parameters.isOfficialBuild, true) }}: + - ${{ if and(eq(variables['Build.Repository.Provider'], 'TfsGit'), eq(variables['Build.Repository.Name'], 'NuGet-Client.Tools'), eq(variables['System.TeamProject'], 'internal'), eq(variables['System.CollectionUri'], 'https://dev.azure.com/dnceng/'), eq(variables['Build.SourceBranch'], 'refs/heads/main'), ne(variables['Build.Reason'], ''), ne(variables['Build.Reason'], 'PullRequest')) }}: + - template: jobs-build.yml + parameters: + isOfficialBuild: true + releaseBuild: ${{ parameters.releaseBuild }} + jobsTemplate: /eng/common/templates-official/jobs/jobs.yml@self + - ${{ else }}: + - template: /eng/common/templates-official/jobs/jobs.yml@self + parameters: + runAsPublic: true + enableMicrobuild: false + enableTelemetry: false + enablePublishBuildAssets: false + enablePublishing: false + jobs: + - job: RejectUntrustedOfficialSource + displayName: Reject untrusted official source without signing resources + steps: + - pwsh: | + throw 'Official signing requires main from the trusted dnceng/internal/NuGet-Client.Tools repository with non-PR metadata.' + displayName: Reject untrusted source + - ${{ else }}: + - template: jobs-build.yml + parameters: + releaseBuild: ${{ parameters.releaseBuild }} diff --git a/eng/pipelines/dotnet-package-skills/steps-build.yml b/eng/pipelines/dotnet-package-skills/steps-build.yml new file mode 100644 index 0000000..b31e4ac --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/steps-build.yml @@ -0,0 +1,103 @@ +parameters: +- name: isOfficialBuild + type: boolean + default: false +- name: releaseBuild + type: boolean + default: false + +steps: +- pwsh: | + foreach ($directory in @( + "$(Build.SourcesDirectory)\artifacts\log\Release", + "$(Build.SourcesDirectory)\artifacts\TestResults\Release", + "$(DotnetPackageSkillsPackageArtifactPath)" + )) { + [IO.Directory]::CreateDirectory($directory) | Out-Null + } + displayName: Prepare native diagnostic and package artifact directories + +- task: UseDotNet@2 + displayName: Install root-pinned .NET SDK into the Arcade installation + inputs: + packageType: sdk + useGlobalJson: true + workingDirectory: $(Build.SourcesDirectory) + installationPath: $(DOTNET_INSTALL_DIR) + +- task: UseDotNet@2 + displayName: Install .NET 8 runtime into the same Arcade installation + inputs: + packageType: runtime + version: 8.0.x + installationPath: $(DOTNET_INSTALL_DIR) + +- pwsh: | + & "$(DotnetPackageSkillsPipelineDirectory)\Invoke-Build.ps1" -Action RestoreBuild -CI ` + -Official:([bool]::Parse('${{ parameters.isOfficialBuild }}')) ` + -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) + displayName: Restore and build the root Arcade graph, including infrastructure validation + workingDirectory: $(Build.SourcesDirectory) + +- pwsh: | + & "$(DotnetPackageSkillsPipelineDirectory)\Invoke-Build.ps1" -Action Test -CI ` + -Official:([bool]::Parse('${{ parameters.isOfficialBuild }}')) ` + -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) + displayName: Run C# tests on both frameworks and reject missing or failed results + workingDirectory: $(Build.SourcesDirectory) + +- task: PublishTestResults@2 + displayName: Publish both C# framework results with fail-closed checks + condition: succeededOrFailed() + inputs: + testResultsFormat: VSTest + testResultsFiles: $(Build.SourcesDirectory)\artifacts\TestResults\Release\DotnetPackageSkills.Tests_*.trx + testRunTitle: dotnet-package-skills + failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true + failTaskOnFailureToPublishResults: true + +- pwsh: | + & "$(DotnetPackageSkillsPipelineDirectory)\Invoke-Build.ps1" -Action Pack -CI ` + -Official:([bool]::Parse('${{ parameters.isOfficialBuild }}')) ` + -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) + displayName: Pack the native shipping package without rebuilding + workingDirectory: $(Build.SourcesDirectory) + +- pwsh: | + & "$(DotnetPackageSkillsPipelineDirectory)\Invoke-Build.ps1" -Action Sign -CI ` + -Official:([bool]::Parse('${{ parameters.isOfficialBuild }}')) ` + -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) + ${{ if eq(parameters.isOfficialBuild, true) }}: + displayName: Recursively real-sign payloads, repack, and sign the package through Arcade + ${{ else }}: + displayName: Validate the Arcade signing plan without signing or production access + workingDirectory: $(Build.SourcesDirectory) + +- pwsh: | + & "$(DotnetPackageSkillsPipelineDirectory)\Invoke-Build.ps1" -Action Verify -CI ` + -Official:([bool]::Parse('${{ parameters.isOfficialBuild }}')) ` + -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) ` + -PackageArtifactPath "$(DotnetPackageSkillsPackageArtifactPath)" + displayName: Verify and install only the exact native package on both runtimes + workingDirectory: $(Build.SourcesDirectory) + +- ${{ if eq(parameters.isOfficialBuild, false) }}: + - task: PublishPipelineArtifact@1 + displayName: Publish the verified unsigned tool package + condition: and(succeeded(), eq(variables['DotnetPackageSkillsPackageVerified'], 'true')) + inputs: + targetPath: $(DotnetPackageSkillsPackageArtifactPath) + artifact: dotnet-package-skills-packages + - task: PublishPipelineArtifact@1 + displayName: Retain native diagnostic logs + condition: succeededOrFailed() + inputs: + targetPath: $(Build.SourcesDirectory)\artifacts\log\Release + artifact: dotnet-package-skills-logs + - task: PublishPipelineArtifact@1 + displayName: Retain both framework result files + condition: succeededOrFailed() + inputs: + targetPath: $(Build.SourcesDirectory)\artifacts\TestResults\Release + artifact: dotnet-package-skills-testresults diff --git a/eng/pipelines/official.yml b/eng/pipelines/official.yml index fd47e1b..e508a8d 100644 --- a/eng/pipelines/official.yml +++ b/eng/pipelines/official.yml @@ -1,3 +1,11 @@ +name: $(Date:yyyyMMdd).$(Rev:r) +appendCommitMessageToRunName: false + +parameters: +- name: DotnetPackageSkillsReleaseBuild + type: boolean + default: false + trigger: - main @@ -18,21 +26,7 @@ extends: demands: ImageOverride -equals windows.vs2026.amd64 os: windows stages: - - stage: Build - jobs: - - template: /eng/common/templates-official/jobs/jobs.yml@self - parameters: - enableMicrobuild: false - enableTelemetry: false - enablePublishBuildAssets: false - artifacts: - publish: - logs: true - jobs: - - job: ArcadeInfrastructure - variables: - - name: _BuildConfig - value: Release - steps: - - script: eng\common\build.cmd -restore -build -configuration $(_BuildConfig) -ci - displayName: Restore and validate Arcade infrastructure + - template: /eng/pipelines/dotnet-package-skills/stage.yml@self + parameters: + isOfficialBuild: true + releaseBuild: ${{ parameters.DotnetPackageSkillsReleaseBuild }} diff --git a/eng/pipelines/pr.yml b/eng/pipelines/pr.yml index 3fd3d12..ed907a7 100644 --- a/eng/pipelines/pr.yml +++ b/eng/pipelines/pr.yml @@ -11,22 +11,4 @@ pool: - ImageOverride -equals windows.vs2026.amd64.open stages: -- stage: Build - jobs: - - template: /eng/common/templates/jobs/jobs.yml - parameters: - runAsPublic: true - enableMicrobuild: false - enableTelemetry: false - enablePublishBuildAssets: false - artifacts: - publish: - logs: true - jobs: - - job: ArcadeInfrastructure - variables: - - name: _BuildConfig - value: Release - steps: - - script: eng\common\build.cmd -restore -build -configuration $(_BuildConfig) -ci - displayName: Restore and validate Arcade infrastructure +- template: /eng/pipelines/dotnet-package-skills/stage.yml From 8bf8e23a2accc0f89c64f8fedd79cc524ee07e23 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 19:42:09 -0700 Subject: [PATCH 02/10] Resolve forwarded Arcade steps from the repository root Server-side Azure expansion resolves steps forwarded through the common job relative to that job template. Use a root-absolute tool steps reference and cover it with a regression test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/PipelineConfigurationTests.cs | 13 ++++++++++++- eng/pipelines/dotnet-package-skills/jobs-build.yml | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/dotnet-package-skills/tests/PipelineConfigurationTests.cs b/dotnet-package-skills/tests/PipelineConfigurationTests.cs index 4a2f67a..875bc5a 100644 --- a/dotnet-package-skills/tests/PipelineConfigurationTests.cs +++ b/dotnet-package-skills/tests/PipelineConfigurationTests.cs @@ -110,6 +110,17 @@ public void Root_entry_points_preserve_main_only_triggers_and_1ES_governance() Assert.Equal("False", Text(release["default"])); } + [Fact] + public void Steps_forwarded_through_Arcade_jobs_use_a_root_absolute_template_reference() + { + var root = Read(PipelineTestContext.PipelinePath("jobs-build.yml")); + var wrapper = Map(Assert.Single(List(root["jobs"]))); + var job = Map(Assert.Single(List(Map(wrapper["parameters"])["jobs"]))); + var step = Map(Assert.Single(List(job["steps"]))); + + Assert.Equal("/eng/pipelines/dotnet-package-skills/steps-build.yml", Text(step["template"])); + } + private static Dictionary TrustedVariables() => new() { ["Build.Repository.Provider"] = "TfsGit", @@ -136,7 +147,7 @@ private static ExpandedPlan ExpandPlan(bool official, Dictionary var steps = new List(); foreach (var step in List(job["steps"]).Select(Map)) { - if (step.TryGetValue("template", out var path) && Text(path) == "steps-build.yml") + if (step.TryGetValue("template", out var path) && Text(path) == "/eng/pipelines/dotnet-package-skills/steps-build.yml") { steps.AddRange(List(ExpandOwned("steps-build.yml", Map(step["parameters"]), variables)["steps"])); } diff --git a/eng/pipelines/dotnet-package-skills/jobs-build.yml b/eng/pipelines/dotnet-package-skills/jobs-build.yml index ffa0432..e5a3a5c 100644 --- a/eng/pipelines/dotnet-package-skills/jobs-build.yml +++ b/eng/pipelines/dotnet-package-skills/jobs-build.yml @@ -86,7 +86,7 @@ jobs: -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) displayName: Validate trusted source and approved signing team before MicroBuild steps: - - template: steps-build.yml + - template: /eng/pipelines/dotnet-package-skills/steps-build.yml parameters: isOfficialBuild: ${{ parameters.isOfficialBuild }} releaseBuild: ${{ parameters.releaseBuild }} From f1f7ea8501d3a195075b1ccc3f86684da7818660 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Tue, 6 Oct 2026 10:51:10 -0700 Subject: [PATCH 03/10] Simplify package-skills pipelines to native Arcade actions Use one shared Windows stage and one Arcade restore/build/test/pack invocation per job. Official trusted-main builds add native recursive signing; both pipelines publish NuGet build artifacts. Remove custom validators, wrapper scripts, pipeline-only tests and redundant template layers while preserving the original application suite and shared foundation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 13 +- dotnet-package-skills/CONTRIBUTING.md | 37 +-- dotnet-package-skills/README.md | 15 +- .../tests/PipelineConfigurationTests.cs | 305 ------------------ .../tests/PipelineInvocationTests.cs | 26 -- .../tests/PipelinePackageTests.cs | 305 ------------------ .../tests/PipelineResultsTests.cs | 115 ------- .../tests/PipelineRetirementTests.cs | 18 -- .../tests/PipelineSigningTests.cs | 42 --- .../tests/PipelineSourceTests.cs | 64 ---- .../tests/PipelineTestContext.cs | 128 -------- .../tests/PipelineVersionTests.cs | 173 ---------- eng/Versions.props | 1 - .../dotnet-package-skills/Build.props | 27 -- .../dotnet-package-skills/Build.targets | 46 --- .../dotnet-package-skills/Invoke-Build.ps1 | 118 ------- .../Package-Validation.psm1 | 209 ------------ .../dotnet-package-skills/Payload.props | 20 -- eng/pipelines/dotnet-package-skills/README.md | 255 ++++----------- .../dotnet-package-skills/Signing.props | 37 +-- .../dotnet-package-skills/Test-Package.ps1 | 15 - .../dotnet-package-skills/Test-Results.ps1 | 51 --- .../dotnet-package-skills/Test-Retirement.ps1 | 153 --------- .../Validate-OfficialSource.ps1 | 22 -- .../dotnet-package-skills/Verify-Package.ps1 | 132 -------- .../dotnet-package-skills/jobs-build.yml | 92 ------ eng/pipelines/dotnet-package-skills/stage.yml | 100 ++++-- .../dotnet-package-skills/steps-build.yml | 103 ------ eng/pipelines/official.yml | 17 +- 29 files changed, 161 insertions(+), 2478 deletions(-) delete mode 100644 dotnet-package-skills/tests/PipelineConfigurationTests.cs delete mode 100644 dotnet-package-skills/tests/PipelineInvocationTests.cs delete mode 100644 dotnet-package-skills/tests/PipelinePackageTests.cs delete mode 100644 dotnet-package-skills/tests/PipelineResultsTests.cs delete mode 100644 dotnet-package-skills/tests/PipelineRetirementTests.cs delete mode 100644 dotnet-package-skills/tests/PipelineSigningTests.cs delete mode 100644 dotnet-package-skills/tests/PipelineSourceTests.cs delete mode 100644 dotnet-package-skills/tests/PipelineTestContext.cs delete mode 100644 dotnet-package-skills/tests/PipelineVersionTests.cs delete mode 100644 eng/pipelines/dotnet-package-skills/Build.props delete mode 100644 eng/pipelines/dotnet-package-skills/Build.targets delete mode 100644 eng/pipelines/dotnet-package-skills/Invoke-Build.ps1 delete mode 100644 eng/pipelines/dotnet-package-skills/Package-Validation.psm1 delete mode 100644 eng/pipelines/dotnet-package-skills/Payload.props delete mode 100644 eng/pipelines/dotnet-package-skills/Test-Package.ps1 delete mode 100644 eng/pipelines/dotnet-package-skills/Test-Results.ps1 delete mode 100644 eng/pipelines/dotnet-package-skills/Test-Retirement.ps1 delete mode 100644 eng/pipelines/dotnet-package-skills/Validate-OfficialSource.ps1 delete mode 100644 eng/pipelines/dotnet-package-skills/Verify-Package.ps1 delete mode 100644 eng/pipelines/dotnet-package-skills/jobs-build.yml delete mode 100644 eng/pipelines/dotnet-package-skills/steps-build.yml diff --git a/README.md b/README.md index 18f2369..2e99f9c 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,7 @@ This repository contains tools shipped by the NuGet Client team to help develope Run from the repository root on Windows, with PowerShell 7 and the root-pinned .NET SDK: ```powershell -eng\common\build.cmd -restore -build -test -configuration Release -eng\common\build.cmd -pack -configuration Release /p:NoBuild=true -eng\common\build.cmd -sign -configuration Release /p:NETCORE_ENGINEERING_TELEMETRY=false -eng\pipelines\dotnet-package-skills\Invoke-Build.ps1 -Action Verify +eng\common\build.cmd -restore -build -test -pack -configuration Release ``` Install the .NET 8 runtime into the same .NET installation that Arcade selects. The SDK supplies @@ -22,8 +19,6 @@ the .NET 10 runtime. The build graph retains `eng\Infrastructure.proj` and inclu solution. Artifacts use Arcade's `artifacts\bin`, `obj`, `log`, `TestResults`, and `packages\Release\Shipping` layout. -Local and public signing actions validate the signing plan without changing signatures. -Real signing requires the trusted internal main pipeline and owner-approved MicroBuild/ESRP -resources. The pipelines publish artifacts only. See the -[tool pipeline guide](eng/pipelines/dotnet-package-skills/README.md) for versions, prerequisites, -verification, and retirement. \ No newline at end of file +The public pipeline produces unsigned packages. The official pipeline uses Arcade's recursive +signing with owner-approved MicroBuild/ESRP resources. Both publish NuGet packages as build +artifacts only. See the [tool pipeline guide](eng/pipelines/dotnet-package-skills/README.md). \ No newline at end of file diff --git a/dotnet-package-skills/CONTRIBUTING.md b/dotnet-package-skills/CONTRIBUTING.md index c2f80f9..0cd7ebe 100644 --- a/dotnet-package-skills/CONTRIBUTING.md +++ b/dotnet-package-skills/CONTRIBUTING.md @@ -14,7 +14,7 @@ Windows only. Use the root SDK, Arcade imports, and public feeds. Do not add a t ```powershell git clone https://github.com/NuGet/Client.Tools.git Set-Location .\Client.Tools -eng\common\build.cmd -restore -build -test -configuration Release +eng\common\build.cmd -restore -build -test -pack -configuration Release ``` Try your build against a real repository without installing it: @@ -24,13 +24,8 @@ eng\common\dotnet.cmd artifacts\bin\DotnetPackageSkills\Release\net10.0\dotnet-p list --target C:\path\to\YourApp.sln ``` -Pack and verify your build without replacing a globally installed tool: - -```powershell -eng\common\build.cmd -pack -configuration Release /p:NoBuild=true -eng\common\build.cmd -sign -configuration Release /p:NETCORE_ENGINEERING_TELEMETRY=false -eng\pipelines\dotnet-package-skills\Invoke-Build.ps1 -Action Verify -``` +The build creates an unsigned tool package under `artifacts\packages\Release\Shipping`. +It does not replace a globally installed tool. Only the official pipeline signs the package. ## Origin @@ -42,7 +37,7 @@ history. The source repository is unchanged. The source README and package metadata name the MIT license. Client.Tools keeps its own root MIT license file. We left out the Python and Node.js terminal regression tests on purpose. The C# picker tests and the interactive tool behavior stay the same. Build configuration and pipeline -integration live in this tool's pipeline folder, with small root build, version, signing, and +integration live in this tool's pipeline folder, with small root build, signing, and stage references. Remove these references with the tool, but keep the shared Arcade foundation. This lets a maintainer retire the tool on its own, for example when its function moves into the .NET SDK. @@ -359,26 +354,10 @@ a buffer instead of joining writes end to end. The picker redraws its frame in p every write together would stack frames on top of each other. A real user sees only one page at a time, and the test model must match that. -### Checks for the pipeline and the package - -`PipelineVersionTests` evaluates Arcade's native MSBuild version properties for both frameworks. -It covers local, public CI, official prerelease, and stable release versions. It rejects -untrusted, automatic, missing, and malformed release inputs. Other `Pipeline*Tests` cover -package metadata and payloads, missing or failed results, trusted-source checks, and the owned -public and official templates. These tests need `pwsh` on the PATH and the restored Arcade -toolset. They use no production signing connection. - -A separate package verifier installs the produced `.nupkg` file for each target framework. It -checks the package version, the package payload, and the install, list, and uninstall behavior, -all inside temporary directories. Official builds also require a valid package signature and -valid signatures on the extracted owned and dependency DLLs. The verifier keeps the strict -SHA256 comparison between packaged owned DLLs and the explicitly listed tested loose copies. -It checks every redistributed strong-name identity and retains the original Microsoft -dependency signatures. - -Keep all pipeline logic that is specific to this tool under `eng\pipelines\dotnet-package-skills`, -at the root of the repository. See its [guide](../eng/pipelines/dotnet-package-skills/README.md) -for version numbering, official signing setup, and the checklist for retiring this tool. +Arcade's standard `-test` action runs the application suite on both frameworks and publishes +the CI results. Keep tool-specific pipeline and signing configuration under +`eng\pipelines\dotnet-package-skills`. See its +[guide](../eng/pipelines/dotnet-package-skills/README.md) for versions and official prerequisites. ### Name unit tests diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md index b91cdce..b12ca36 100644 --- a/dotnet-package-skills/README.md +++ b/dotnet-package-skills/README.md @@ -588,20 +588,13 @@ Install PowerShell 7. Install the .NET 8 runtime into the same .NET installation uses. The SDK supplies the .NET 10 runtime. The CI system validates these commands on Windows. ```powershell -eng\common\build.cmd -restore -build -test -configuration Release -eng\common\build.cmd -pack -configuration Release /p:NoBuild=true -eng\common\build.cmd -sign -configuration Release /p:NETCORE_ENGINEERING_TELEMETRY=false -eng\pipelines\dotnet-package-skills\Invoke-Build.ps1 -Action Verify +eng\common\build.cmd -restore -build -test -pack -configuration Release ``` Arcade keeps the binaries under `artifacts\bin\DotnetPackageSkills\Release` and the package under -`artifacts\packages\Release\Shipping`. The signing command above is a dry run. It needs no -production connection and leaves the package unsigned. - -The verifier reads the expected version and paths from Arcade. It installs the exact package into a temporary tool path, once for -.NET 8 and once for .NET 10. It runs the package's non-interactive commands, and then it removes -its own temporary files. It does not replace a tool that you installed globally, and it does not -change your installed skills. +`artifacts\packages\Release\Shipping`. Local and public builds leave the package unsigned. +Official builds use Arcade to sign the nested assemblies, repack the package, and sign the +NuGet package. The original C# application tests run on both frameworks. A local build uses `0.1.0-dev`. A public CI or PR build uses `0.1.0-ci`. An ordinary official build uses `0.1.0-beta..`. diff --git a/dotnet-package-skills/tests/PipelineConfigurationTests.cs b/dotnet-package-skills/tests/PipelineConfigurationTests.cs deleted file mode 100644 index 875bc5a..0000000 --- a/dotnet-package-skills/tests/PipelineConfigurationTests.cs +++ /dev/null @@ -1,305 +0,0 @@ -using System.Globalization; -using System.Text.Json; -using System.Text.RegularExpressions; -using SharpYaml; -using SharpYaml.Events; - -namespace DotnetPackageSkills.Tests; - -public class PipelineConfigurationTests -{ - [Fact] - public void Public_plan_excludes_production_resources_even_with_internal_source_variables() - { - var plan = ExpandPlan(official: false, TrustedVariables()); - - Assert.Equal("/eng/common/templates/jobs/jobs.yml", plan.Template); - Assert.Equal("True", Text(plan.Parameters["runAsPublic"])); - Assert.Equal("False", Text(plan.Parameters["enableMicrobuild"])); - Assert.Equal("False", Text(plan.Parameters["enablePublishBuildAssets"])); - Assert.Equal("False", Text(plan.Parameters["enablePublishing"])); - Assert.DoesNotContain("_SignType", plan.JobText); - Assert.DoesNotContain("_TeamName", plan.JobText); - Assert.DoesNotContain("SigningTeamName", plan.JobText); - Assert.DoesNotContain("templateContext", plan.Job.Keys.Cast()); - Assert.Contains("PublishPipelineArtifact@1", plan.JobText); - } - - [Fact] - public void Trusted_official_plan_uses_Arcade_MicroBuild_and_governed_success_only_packages() - { - var plan = ExpandPlan(official: true, TrustedVariables()); - - Assert.Equal("/eng/common/templates-official/jobs/jobs.yml@self", plan.Template); - Assert.Equal("False", Text(plan.Parameters["runAsPublic"])); - Assert.Equal("True", Text(plan.Parameters["enableMicrobuild"])); - Assert.Equal("False", Text(plan.Parameters["enableMicrobuildForMacAndLinux"])); - Assert.Contains("_SignType", plan.JobText); - Assert.Contains("real", plan.JobText); - Assert.Contains("Validate trusted source", plan.JobText); - Assert.DoesNotContain("PublishPipelineArtifact@1", plan.JobText); - var outputs = List(Map(plan.Job["templateContext"])["outputs"]).Select(Map).ToArray(); - Assert.Equal(3, outputs.Length); - var packages = Assert.Single(outputs, output => Text(output["artifactName"]) == "dotnet-package-skills-packages"); - Assert.Contains("succeeded()", Text(packages["condition"])); - Assert.Contains("DotnetPackageSkillsPackageVerified", Text(packages["condition"])); - Assert.All(outputs.Where(output => !ReferenceEquals(output, packages)), - output => Assert.Equal("False", Text(output["isProduction"]))); - } - - [Theory] - [InlineData("Build.Repository.Provider", "GitHub")] - [InlineData("Build.Repository.Name", "NuGet/Client.Tools")] - [InlineData("System.TeamProject", "public")] - [InlineData("System.CollectionUri", "https://dev.azure.com/dnceng-public/")] - [InlineData("Build.SourceBranch", "refs/heads/feature")] - [InlineData("Build.Reason", "PullRequest")] - [InlineData("Build.Reason", "")] - public void Untrusted_official_plans_reject_before_including_signing_resources(string name, string value) - { - var variables = TrustedVariables(); - variables[name] = value; - - var plan = ExpandPlan(official: true, variables); - - Assert.Equal("True", Text(plan.Parameters["runAsPublic"])); - Assert.Equal("False", Text(plan.Parameters["enableMicrobuild"])); - Assert.Equal("RejectUntrustedOfficialSource", Text(plan.Job["job"])); - Assert.DoesNotContain("_SignType", plan.JobText); - Assert.DoesNotContain("_TeamName", plan.JobText); - Assert.DoesNotContain("SigningTeamName", plan.JobText); - Assert.DoesNotContain("Invoke-Build.ps1", plan.JobText); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void Expanded_steps_use_one_root_graph_and_never_pack_after_signing(bool official) - { - var plan = ExpandPlan(official, TrustedVariables()); - var steps = List(plan.Job["steps"]).Select(Map).ToArray(); - var scripts = steps.Where(step => step.ContainsKey("pwsh")).Select(step => Text(step["pwsh"])).ToArray(); - Assert.Equal(new[] { "RestoreBuild", "Test", "Pack", "Sign", "Verify" }, - scripts.Select(script => Regex.Match(script, @"-Action (\w+)").Groups[1].Value).Where(value => value != "")); - Assert.All(steps.Where(step => step.ContainsKey("workingDirectory")), - step => Assert.Equal("$(Build.SourcesDirectory)", Text(step["workingDirectory"]))); - var runtimes = steps.Where(step => step.TryGetValue("task", out var task) && Text(task) == "UseDotNet@2").ToArray(); - Assert.Equal(2, runtimes.Length); - Assert.All(runtimes, step => Assert.Equal("$(DOTNET_INSTALL_DIR)", Text(Map(step["inputs"])["installationPath"]))); - var publish = Assert.Single(steps, step => step.TryGetValue("task", out var task) && Text(task) == "PublishTestResults@2"); - Assert.Equal("True", Text(Map(publish["inputs"])["failTaskOnFailedTests"])); - Assert.Equal("True", Text(Map(publish["inputs"])["failTaskOnMissingResultsFile"])); - Assert.Equal("True", Text(Map(publish["inputs"])["failTaskOnFailureToPublishResults"])); - } - - [Fact] - public void Root_entry_points_preserve_main_only_triggers_and_1ES_governance() - { - var publicRoot = Read(Path.Combine(PipelineTestContext.RepoRoot, "eng", "pipelines", "pr.yml")); - Assert.Equal(new[] { "main" }, List(Map(Map(publicRoot["pr"])["branches"])["include"]).Select(Text)); - Assert.False(publicRoot.ContainsKey("resources")); - Assert.Single(List(publicRoot["stages"])); - - var officialRoot = Read(Path.Combine(PipelineTestContext.RepoRoot, "eng", "pipelines", "official.yml")); - Assert.Equal("$(Date:yyyyMMdd).$(Rev:r)", Text(officialRoot["name"])); - Assert.Equal(new[] { "main" }, List(officialRoot["trigger"]).Select(Text)); - Assert.Equal("v1/1ES.Official.PipelineTemplate.yml@1esPipelines", Text(Map(officialRoot["extends"])["template"])); - Assert.Single(List(Map(Map(officialRoot["extends"])["parameters"])["stages"])); - var release = Assert.Single(List(officialRoot["parameters"]).Select(Map)); - Assert.Equal("DotnetPackageSkillsReleaseBuild", Text(release["name"])); - Assert.Equal("False", Text(release["default"])); - } - - [Fact] - public void Steps_forwarded_through_Arcade_jobs_use_a_root_absolute_template_reference() - { - var root = Read(PipelineTestContext.PipelinePath("jobs-build.yml")); - var wrapper = Map(Assert.Single(List(root["jobs"]))); - var job = Map(Assert.Single(List(Map(wrapper["parameters"])["jobs"]))); - var step = Map(Assert.Single(List(job["steps"]))); - - Assert.Equal("/eng/pipelines/dotnet-package-skills/steps-build.yml", Text(step["template"])); - } - - private static Dictionary TrustedVariables() => new() - { - ["Build.Repository.Provider"] = "TfsGit", - ["Build.Repository.Name"] = "NuGet-Client.Tools", - ["System.TeamProject"] = "internal", - ["System.CollectionUri"] = "https://dev.azure.com/dnceng/", - ["Build.SourceBranch"] = "refs/heads/main", - ["Build.Reason"] = "Manual", - }; - - private static ExpandedPlan ExpandPlan(bool official, Dictionary variables) - { - var root = ExpandOwned("stage.yml", new() { ["isOfficialBuild"] = official }, variables); - var stage = Map(Assert.Single(List(root["stages"]))); - Assert.Equal("DotnetPackageSkills", Text(stage["stage"])); - var template = Map(Assert.Single(List(stage["jobs"]))); - if (Text(template["template"]) == "jobs-build.yml") - { - var jobs = ExpandOwned("jobs-build.yml", Map(template["parameters"]), variables); - template = Map(Assert.Single(List(jobs["jobs"]))); - } - var parameters = Map(template["parameters"]); - var job = Map(Assert.Single(List(parameters["jobs"]))); - var steps = new List(); - foreach (var step in List(job["steps"]).Select(Map)) - { - if (step.TryGetValue("template", out var path) && Text(path) == "/eng/pipelines/dotnet-package-skills/steps-build.yml") - { - steps.AddRange(List(ExpandOwned("steps-build.yml", Map(step["parameters"]), variables)["steps"])); - } - else { steps.Add(step); } - } - job["steps"] = steps; - return new(Text(template["template"]), parameters, job); - } - - // Expand owned templates only. Server-side public expansion and the authorized 1ES run are separate acceptance checks. - private static Dictionary ExpandOwned(string file, Dictionary overrides, - Dictionary variables) - { - var root = Read(PipelineTestContext.PipelinePath(file)); - var parameters = List(root["parameters"]).Select(Map).ToDictionary(parameter => Text(parameter["name"]), parameter => parameter["default"]); - foreach (var (key, value) in overrides) { parameters[Text(key)] = value; } - return Map(Expand(root, parameters, variables)); - } - - private static object Expand(object node, Dictionary parameters, Dictionary variables) - { - if (node is string text) - { - if (text.StartsWith("${{ ", StringComparison.Ordinal) && text.EndsWith(" }}", StringComparison.Ordinal) && - text.IndexOf("${{ ", 4, StringComparison.Ordinal) < 0) - { - return Evaluate(text[4..^3], parameters, variables); - } - return Regex.Replace(text, @"\$\{\{ (.*?) \}\}", match => Text(Evaluate(match.Groups[1].Value, parameters, variables))); - } - if (node is Dictionary mapping) - { - var expanded = new Dictionary(); - var matched = false; - foreach (var (key, value) in mapping) - { - if (Text(key).StartsWith("${{ if ", StringComparison.Ordinal)) - { - matched = Convert.ToBoolean(Evaluate(Text(key)[7..^3], parameters, variables), CultureInfo.InvariantCulture); - if (matched) - { - foreach (var (nestedKey, nestedValue) in Map(Expand(value, parameters, variables))) { expanded.Add(nestedKey, nestedValue); } - } - } - else if (Text(key) == "${{ else }}") - { - if (!matched) - { - foreach (var (nestedKey, nestedValue) in Map(Expand(value, parameters, variables))) { expanded.Add(nestedKey, nestedValue); } - } - } - else { expanded.Add(key, Expand(value, parameters, variables)); } - } - return expanded; - } - if (node is List sequence) - { - var expanded = new List(); - var matched = false; - foreach (var item in sequence) - { - if (item is Dictionary conditional && conditional.Count == 1 && - Text(conditional.Keys.Single()).StartsWith("${{ if ", StringComparison.Ordinal)) - { - matched = Convert.ToBoolean(Evaluate(Text(conditional.Keys.Single())[7..^3], parameters, variables), CultureInfo.InvariantCulture); - if (matched) { expanded.AddRange(List(Expand(conditional.Values.Single(), parameters, variables))); } - } - else if (item is Dictionary alternative && alternative.Count == 1 && alternative.ContainsKey("${{ else }}")) - { - if (!matched) { expanded.AddRange(List(Expand(alternative["${{ else }}"], parameters, variables))); } - } - else { expanded.Add(Expand(item, parameters, variables)); } - } - return expanded; - } - return node; - } - - private static object Evaluate(string expression, Dictionary parameters, Dictionary variables) - { - expression = expression.Trim(); - if (bool.TryParse(expression, out var boolean)) { return boolean; } - if (expression.StartsWith('\'') && expression.EndsWith('\'')) { return expression[1..^1]; } - if (expression.StartsWith("parameters.", StringComparison.Ordinal)) { return parameters[expression[11..]]; } - if (expression.StartsWith("variables['", StringComparison.Ordinal)) { return variables.GetValueOrDefault(expression[11..^2], ""); } - var opening = expression.IndexOf('('); - if (opening < 0 || !expression.EndsWith(')')) { throw new InvalidOperationException($"Unsupported template expression: {expression}"); } - var arguments = SplitArguments(expression[(opening + 1)..^1]).Select(argument => Evaluate(argument, parameters, variables)).ToArray(); - return expression[..opening] switch - { - "eq" => string.Equals(Text(arguments[0]), Text(arguments[1]), StringComparison.OrdinalIgnoreCase), - "ne" => !string.Equals(Text(arguments[0]), Text(arguments[1]), StringComparison.OrdinalIgnoreCase), - "and" => arguments.All(argument => Convert.ToBoolean(argument, CultureInfo.InvariantCulture)), - "not" => !Convert.ToBoolean(arguments[0], CultureInfo.InvariantCulture), - _ => throw new InvalidOperationException($"Unsupported template expression: {expression}"), - }; - } - - private static IEnumerable SplitArguments(string expression) - { - var start = 0; - var depth = 0; - var quoted = false; - for (var index = 0; index < expression.Length; index++) - { - var character = expression[index]; - if (character == '\'') { quoted = !quoted; } - if (quoted) { continue; } - if (character == '(') { depth++; } - if (character == ')') { depth--; } - if (character == ',' && depth == 0) { yield return expression[start..index]; start = index + 1; } - } - yield return expression[start..]; - } - - private static Dictionary Read(string path) - { - var reader = new EventReader(Parser.CreateParser(new StringReader(File.ReadAllText(path)))); - reader.Expect(); - reader.Expect(); - var mapping = Map(ReadNode(reader)); - reader.Expect(); - reader.Expect(); - return mapping; - } - - private static object ReadNode(EventReader reader) - { - if (reader.Allow() is { } scalar) - { - if (scalar.Style == ScalarStyle.Plain && bool.TryParse(scalar.Value, out var boolean)) { return boolean; } - return scalar.Value; - } - if (reader.Allow() is not null) - { - var mapping = new Dictionary(); - while (reader.Allow() is null) { mapping.Add(ReadNode(reader), ReadNode(reader)); } - return mapping; - } - if (reader.Allow() is not null) - { - var sequence = new List(); - while (reader.Allow() is null) { sequence.Add(ReadNode(reader)); } - return sequence; - } - throw new InvalidOperationException("Unsupported YAML node in pipeline configuration."); - } - private static Dictionary Map(object value) => Assert.IsType>(value); - private static List List(object value) => Assert.IsType>(value); - private static string Text(object value) => Convert.ToString(value, CultureInfo.InvariantCulture) ?? throw new InvalidOperationException("Missing YAML value."); - private sealed record ExpandedPlan(string Template, Dictionary Parameters, Dictionary Job) - { - internal string JobText => JsonSerializer.Serialize(Job); - } -} diff --git a/dotnet-package-skills/tests/PipelineInvocationTests.cs b/dotnet-package-skills/tests/PipelineInvocationTests.cs deleted file mode 100644 index 416dc00..0000000 --- a/dotnet-package-skills/tests/PipelineInvocationTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Text.Json; - -namespace DotnetPackageSkills.Tests; - -public class PipelineInvocationTests -{ - [Fact] - public async Task Metadata_uses_Arcades_actual_sdk_without_requiring_an_optional_sdk_cache_file() - { - var result = await PipelineTestContext.Run("pwsh", - [ - "-NoLogo", "-NoProfile", "-NonInteractive", "-File", - PipelineTestContext.PipelinePath("Invoke-Build.ps1"), "-Action", "Metadata", - ]); - - Assert.True(result.ExitCode == 0, result.Diagnostics); - using var document = JsonDocument.Parse(result.Output); - var metadata = document.RootElement; - Assert.Equal("0.1.0-dev", metadata.GetProperty("PackageVersion").GetString()); - Assert.Equal(metadata.GetProperty("Version").GetString(), metadata.GetProperty("PackageVersion").GetString()); - Assert.True(File.Exists(metadata.GetProperty("DotnetPath").GetString()), result.Output); - Assert.True(File.Exists(metadata.GetProperty("DotnetPackageSkillsStrongNameToolPath").GetString()), result.Output); - Assert.EndsWith("dotnet-package-skills.0.1.0-dev.nupkg", - metadata.GetProperty("DotnetPackageSkillsPackagePath").GetString()); - } -} diff --git a/dotnet-package-skills/tests/PipelinePackageTests.cs b/dotnet-package-skills/tests/PipelinePackageTests.cs deleted file mode 100644 index 9346aec..0000000 --- a/dotnet-package-skills/tests/PipelinePackageTests.cs +++ /dev/null @@ -1,305 +0,0 @@ -using System.IO.Compression; -using System.Reflection; -using System.Security; -using System.Text; - -namespace DotnetPackageSkills.Tests; - -public class PipelinePackageTests -{ - [Fact] - public async Task Exact_package_installs_and_runs_on_both_runtimes() - { - using var fixture = new PipelinePackageFixture(); - - var result = await fixture.Verify(); - - Assert.True(result.ExitCode == 0, result.Diagnostics); - Assert.Contains("on .NET 8 and .NET 10", result.Output); - } - - [Theory] - [InlineData("tools/net8.0/any/Unmapped.dll")] - [InlineData("tools/net10.0/any/Unmapped.exe")] - [InlineData("tools/net8.0/win-x64/Unmapped.dll")] - [InlineData("tools/net9.0/any/Unmapped.dll")] - public async Task Undeclared_or_nonportable_payloads_fail_before_install(string entry) - { - using var fixture = new PipelinePackageFixture(); - fixture.SetEntry(entry, File.ReadAllBytes(fixture.OwnedAssembly("net8.0"))); - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("payload", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Theory] - [InlineData("tools/net8.0/any/SharpYaml.dll")] - [InlineData("tools/net8.0/any/System.Collections.Immutable.dll")] - [InlineData("tools/net10.0/any/System.CommandLine.dll")] - [InlineData("tools/net10.0/any/fr/System.CommandLine.resources.dll")] - public async Task Missing_declared_dependencies_fail_before_install(string entry) - { - using var fixture = new PipelinePackageFixture(); - fixture.RemoveEntry(entry); - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("missing", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - Assert.Contains(entry, result.Diagnostics); - } - - [Theory] - [InlineData("tools/net8.0/any/SharpYaml.dll")] - [InlineData("tools/net10.0/any/dotnet-package-skills.dll")] - public async Task Empty_signable_payloads_are_not_silently_skipped(string entry) - { - using var fixture = new PipelinePackageFixture(); - fixture.SetEntry(entry, []); - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("empty", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Theory] - [InlineData("tools/net8.0/any/SharpYaml.dll")] - [InlineData("tools/net10.0/any/fr/System.CommandLine.resources.dll")] - public async Task Redistributed_assembly_identity_cannot_change(string entry) - { - using var fixture = new PipelinePackageFixture(); - fixture.SetEntry(entry, File.ReadAllBytes(fixture.OwnedAssembly("net8.0"))); - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("identity", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Theory] - [InlineData("net8.0")] - [InlineData("net10.0")] - public async Task Owned_package_payload_must_match_the_tested_loose_assembly(string framework) - { - using var fixture = new PipelinePackageFixture(); - var bytes = File.ReadAllBytes(fixture.OwnedAssembly(framework)); - bytes[^1] ^= 1; - fixture.SetEntry($"tools/{framework}/any/dotnet-package-skills.dll", bytes); - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("does not match", result.Diagnostics); - } - - [Theory] - [InlineData("dotnet-package-skills", "renamed-tool", "ID")] - [InlineData("dotnet-package-skills contributors", "Changed", "authors")] - [InlineData("MIT", "Apache-2.0", "MIT")] - [InlineData("url=\"https://github.com/NuGet/Client.Tools\"", "url=\"https://example.invalid/tool\"", "origin")] - [InlineData("false", "true", "license acceptance")] - public async Task Package_identity_license_and_origin_metadata_are_preserved( - string original, string replacement, string diagnostic) - { - using var fixture = new PipelinePackageFixture(); - fixture.SetEntry("dotnet-package-skills.nuspec", Encoding.UTF8.GetBytes(fixture.Nuspec.Replace(original, replacement))); - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains(diagnostic, result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Version_drift_is_rejected() - { - using var fixture = new PipelinePackageFixture(); - - var result = await fixture.Verify(expectedVersion: "0.9.0"); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("does not match", result.Diagnostics); - } - - [Fact] - public async Task Missing_readme_is_rejected() - { - using var fixture = new PipelinePackageFixture(); - fixture.RemoveEntry("README.md"); - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("README.md", result.Diagnostics); - } - - [Fact] - public async Task Duplicate_archive_entries_are_rejected() - { - using var fixture = new PipelinePackageFixture(); - using (var archive = ZipFile.Open(fixture.PackagePath, ZipArchiveMode.Update)) - { - using var writer = new StreamWriter(archive.CreateEntry("README.md").Open()); - writer.Write("Duplicate README."); - } - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("duplicate", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Archive_paths_cannot_escape_verification_scratch_space() - { - using var fixture = new PipelinePackageFixture(); - fixture.SetEntry("../escape.dll", File.ReadAllBytes(fixture.OwnedAssembly("net8.0"))); - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("path", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Unsigned_package_cannot_be_an_official_output() - { - using var fixture = new PipelinePackageFixture(); - - var result = await fixture.Verify(requireSigned: true); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("NuGet signature", result.Diagnostics); - } - - [Fact] - public async Task Missing_package_is_rejected() - { - using var fixture = new PipelinePackageFixture(); - File.Delete(fixture.PackagePath); - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - } - - [Fact] - public async Task A_corrupt_package_does_not_leave_extracted_payload_scratch_space() - { - using var fixture = new PipelinePackageFixture(); - File.WriteAllText(fixture.PackagePath, "Not a ZIP file."); - var before = Directory.GetDirectories(Path.GetTempPath(), "dotnet-package-skills-payload-*").ToHashSet(); - - var result = await fixture.Verify(); - - Assert.NotEqual(0, result.ExitCode); - var leaked = Directory.GetDirectories(Path.GetTempPath(), "dotnet-package-skills-payload-*") - .Where(directory => !before.Contains(directory)).ToArray(); - try { Assert.Empty(leaked); } - finally - { - foreach (var directory in leaked) { Directory.Delete(directory, recursive: true); } - } - } -} - -internal sealed class PipelinePackageFixture : IDisposable -{ - private readonly TempDirectory temporary = new(); - private static readonly Lazy> StrongNameTool = new(async () => - { - var properties = PipelineTestContext.Properties(await PipelineTestContext.Evaluate("net8.0")); - return Path.Combine(properties["NuGetPackageRoot"], "sn", properties["SNVersion"], "sn.exe"); - }); - - internal PipelinePackageFixture() - { - Version = typeof(PackageSkillsException).Assembly.GetCustomAttribute()! - .InformationalVersion.Split('+', 2)[0]; - BuildOutputPath = temporary.CreateDirectory("build"); - PackagePath = temporary.Combine($"dotnet-package-skills.{Version}.nupkg"); - foreach (var framework in new[] { "net8.0", "net10.0" }) - { - var source = Path.Combine(PipelineTestContext.RepoRoot, "artifacts", "bin", "DotnetPackageSkills", "Release", framework); - foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) - { - var relative = Path.GetRelativePath(source, file); - if (relative.StartsWith("publish" + Path.DirectorySeparatorChar, StringComparison.Ordinal)) { continue; } - if (Path.GetExtension(file) is not (".dll" or ".json")) { continue; } - var copy = Path.Combine(BuildOutputPath, framework, relative); - Directory.CreateDirectory(Path.GetDirectoryName(copy)!); - File.Copy(file, copy); - } - } - using (var archive = ZipFile.Open(PackagePath, ZipArchiveMode.Create)) - { - foreach (var framework in new[] { "net8.0", "net10.0" }) - { - var directory = Path.Combine(BuildOutputPath, framework); - foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) - { - archive.CreateEntryFromFile(file, $"tools/{framework}/any/{Path.GetRelativePath(directory, file).Replace('\\', '/')}"); - } - WriteEntry(archive, $"tools/{framework}/any/DotnetToolSettings.xml", - ""); - } - WriteEntry(archive, "dotnet-package-skills.nuspec", Nuspec); - WriteEntry(archive, "README.md", "Package verification fixture."); - } - } - - internal string Version { get; } - internal string BuildOutputPath { get; } - internal string PackagePath { get; } - internal string Nuspec => - "dotnet-package-skills" + - $"{SecurityElement.Escape(Version)}" + - "dotnet-package-skills contributors" + - "Package verification fixture." + - "false" + - "MITREADME.md" + - "" + - ""; - - internal string OwnedAssembly(string framework) => Path.Combine(BuildOutputPath, framework, "dotnet-package-skills.dll"); - - internal async Task Verify(bool requireSigned = false, string? expectedVersion = null) - { - var arguments = new List - { - "-NoLogo", "-NoProfile", "-NonInteractive", "-File", PipelineTestContext.PipelinePath("Verify-Package.ps1"), - "-PackagePath", PackagePath, "-ExpectedVersion", expectedVersion ?? Version, - "-BuildOutputPath", BuildOutputPath, - "-DotnetPath", PipelineTestContext.DotnetPath, - "-StrongNameToolPath", await StrongNameTool.Value, - }; - if (requireSigned) { arguments.Add("-RequireSigned"); } - return await PipelineTestContext.Run("pwsh", arguments); - } - - internal void SetEntry(string name, byte[] bytes) - { - using var archive = ZipFile.Open(PackagePath, ZipArchiveMode.Update); - archive.GetEntry(name)?.Delete(); - using var stream = archive.CreateEntry(name).Open(); - stream.Write(bytes); - } - - internal void RemoveEntry(string name) - { - using var archive = ZipFile.Open(PackagePath, ZipArchiveMode.Update); - archive.GetEntry(name)!.Delete(); - } - - private static void WriteEntry(ZipArchive archive, string name, string content) - { - using var writer = new StreamWriter(archive.CreateEntry(name).Open(), new UTF8Encoding(false)); - writer.Write(content); - } - - public void Dispose() => temporary.Dispose(); -} diff --git a/dotnet-package-skills/tests/PipelineResultsTests.cs b/dotnet-package-skills/tests/PipelineResultsTests.cs deleted file mode 100644 index 959af85..0000000 --- a/dotnet-package-skills/tests/PipelineResultsTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -namespace DotnetPackageSkills.Tests; - -public class PipelineResultsTests -{ - [Fact] - public async Task Both_frameworks_require_nonempty_successful_results() - { - using var temporary = new TempDirectory(); - WriteResults(temporary, "net8.0"); - WriteResults(temporary, "net10.0"); - - var result = await Check(temporary.Path); - - Assert.True(result.ExitCode == 0, result.Diagnostics); - Assert.Contains("net8.0", result.Output); - Assert.Contains("net10.0", result.Output); - } - - [Theory] - [InlineData("net8.0")] - [InlineData("net10.0")] - public async Task Missing_framework_results_fail_closed(string missing) - { - using var temporary = new TempDirectory(); - WriteResults(temporary, missing == "net8.0" ? "net10.0" : "net8.0"); - - var result = await Check(temporary.Path); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("missing", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - Assert.Contains(missing, result.Diagnostics); - } - - [Theory] - [InlineData("0", "0", "0", "0")] - [InlineData("2", "1", "1", "0")] - [InlineData("2", "2", "1", "1")] - [InlineData("garbage", "2", "2", "0")] - public async Task Empty_failed_incomplete_or_malformed_counters_fail_closed( - string total, string executed, string passed, string failed) - { - using var temporary = new TempDirectory(); - WriteResults(temporary, "net8.0", total, executed, passed, failed); - WriteResults(temporary, "net10.0"); - - var result = await Check(temporary.Path); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("results", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Passed_counters_cannot_hide_a_failed_individual_result() - { - using var temporary = new TempDirectory(); - var file = WriteResults(temporary, "net8.0"); - File.WriteAllText(file, File.ReadAllText(file).Replace("outcome=\"Passed\"", "outcome=\"Failed\"")); - WriteResults(temporary, "net10.0"); - - var result = await Check(temporary.Path); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("results", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task A_previous_run_cannot_supply_missing_current_results() - { - using var temporary = new TempDirectory(); - File.SetLastWriteTimeUtc(WriteResults(temporary, "net8.0"), DateTime.UtcNow.AddHours(-1)); - WriteResults(temporary, "net10.0"); - - var result = await Check(temporary.Path, DateTime.UtcNow.AddMinutes(-1)); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("stale", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Build_configuration_tests_cannot_replace_the_functional_suite() - { - using var temporary = new TempDirectory(); - WriteResults(temporary, "net8.0"); - WriteResults(temporary, "net10.0"); - var result = await PipelineTestContext.Run("pwsh", - [ - "-NoLogo", "-NoProfile", "-NonInteractive", "-File", PipelineTestContext.PipelinePath("Test-Results.ps1"), - "-ResultsDirectory", temporary.Path, "-MinimumFunctionalTests", "792", - ]); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("functional", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - private static string WriteResults(TempDirectory directory, string framework, - string total = "2", string executed = "2", string passed = "2", string failed = "0") => - directory.CreateFile($"DotnetPackageSkills.Tests_{framework}_x64.trx", - "" + - $""); - - private static Task Check(string directory, DateTime? notBefore = null) - { - var arguments = new List - { - "-NoLogo", "-NoProfile", "-NonInteractive", "-File", PipelineTestContext.PipelinePath("Test-Results.ps1"), - "-ResultsDirectory", directory, - }; - if (notBefore is not null) - { - arguments.AddRange(["-NotBeforeUtc", notBefore.Value.ToString("O")]); - } - return PipelineTestContext.Run("pwsh", arguments); - } -} diff --git a/dotnet-package-skills/tests/PipelineRetirementTests.cs b/dotnet-package-skills/tests/PipelineRetirementTests.cs deleted file mode 100644 index 244f530..0000000 --- a/dotnet-package-skills/tests/PipelineRetirementTests.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace DotnetPackageSkills.Tests; - -public class PipelineRetirementTests -{ - [Fact] - public async Task Isolated_retirement_keeps_the_Arcade_foundation_and_another_tool() - { - var result = await PipelineTestContext.Run("pwsh", - [ - "-NoLogo", "-NoProfile", "-NonInteractive", "-File", PipelineTestContext.PipelinePath("Test-Retirement.ps1"), - ]); - - Assert.True(result.ExitCode == 0, result.Diagnostics); - Assert.Contains("Arcade infrastructure validation succeeded", result.Output); - Assert.Contains("Future tool foundation validation succeeded", result.Output); - Assert.Contains("Isolated retirement validation succeeded", result.Output); - } -} diff --git a/dotnet-package-skills/tests/PipelineSigningTests.cs b/dotnet-package-skills/tests/PipelineSigningTests.cs deleted file mode 100644 index e28aee6..0000000 --- a/dotnet-package-skills/tests/PipelineSigningTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace DotnetPackageSkills.Tests; - -public class PipelineSigningTests -{ - [Theory] - [InlineData("package", false)] - [InlineData("package", true)] - [InlineData("net8.0", false)] - [InlineData("net10.0", true)] - public async Task Explicit_signing_inputs_reject_missing_or_empty_package_and_loose_Dlls(string input, bool empty) - { - using var temporary = new TempDirectory(); - using var package = new PipelinePackageFixture(); - var metadata = PipelineTestContext.Properties(await PipelineTestContext.Evaluate("net8.0")); - var artifacts = temporary.CreateDirectory("artifacts"); - var inputs = new Dictionary - { - ["package"] = Path.Combine(temporary.CreateDirectory("artifacts", "packages", "Release", "Shipping"), - $"dotnet-package-skills.{metadata["PackageVersion"]}.nupkg"), - }; - File.Copy(package.PackagePath, inputs["package"]); - foreach (var framework in new[] { "net8.0", "net10.0" }) - { - inputs[framework] = Path.Combine(temporary.CreateDirectory("artifacts", "bin", "DotnetPackageSkills", "Release", framework), - "dotnet-package-skills.dll"); - File.Copy(package.OwnedAssembly(framework), inputs[framework]); - } - if (empty) { File.WriteAllBytes(inputs[input], []); } - else { File.Delete(inputs[input]); } - - var result = await PipelineTestContext.Run(Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", - [ - "/c", Path.Combine(PipelineTestContext.RepoRoot, "eng", "common", "build.cmd"), - "-sign", "-configuration", "Release", "/p:OfficialBuildId=", - $"/p:ArtifactsDir={artifacts}{Path.DirectorySeparatorChar}", "/p:NETCORE_ENGINEERING_TELEMETRY=false", - ]); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains(empty ? "empty signing input" : "missing signing input", result.Diagnostics); - Assert.DoesNotContain("SignToolTask starting", result.Diagnostics); - } -} diff --git a/dotnet-package-skills/tests/PipelineSourceTests.cs b/dotnet-package-skills/tests/PipelineSourceTests.cs deleted file mode 100644 index 5e85560..0000000 --- a/dotnet-package-skills/tests/PipelineSourceTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -namespace DotnetPackageSkills.Tests; - -public class PipelineSourceTests -{ - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Official_source_guard_accepts_only_an_explicit_trusted_team(bool release) - { - var result = await Check(release: release); - - Assert.True(result.ExitCode == 0, result.Diagnostics); - } - - [Theory] - [InlineData("BUILD_REPOSITORY_PROVIDER", "GitHub")] - [InlineData("BUILD_REPOSITORY_NAME", "NuGet/Client.Tools")] - [InlineData("SYSTEM_TEAMPROJECT", "public")] - [InlineData("SYSTEM_COLLECTIONURI", "https://dev.azure.com/dnceng-public/")] - [InlineData("BUILD_SOURCEBRANCH", "refs/heads/feature")] - [InlineData("BUILD_REASON", "PullRequest")] - [InlineData("BUILD_REASON", "")] - public async Task Official_source_guard_rejects_untrusted_or_missing_metadata(string name, string value) - { - var result = await Check(name, value); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("trusted", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Theory] - [InlineData("")] - [InlineData("$(DotnetPackageSkillsSigningTeamName)")] - [InlineData("unreviewed;property")] - public async Task Official_source_guard_requires_a_configured_signing_team(string value) - { - var result = await Check("DOTNET_PACKAGE_SKILLS_SIGNING_TEAM", value); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("team", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task An_automatic_official_run_cannot_request_a_stable_release() - { - var result = await Check("BUILD_REASON", "IndividualCI", release: true); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("manual", result.Diagnostics, StringComparison.OrdinalIgnoreCase); - } - - private static Task Check(string? name = null, string? value = null, bool release = false) - { - var environment = PipelineTestContext.OfficialInputs(); - environment["DOTNET_PACKAGE_SKILLS_SIGNING_TEAM"] = "FixtureApprovedTeam"; - if (name is not null) { environment[name] = value!; } - var arguments = new List - { - "-NoLogo", "-NoProfile", "-NonInteractive", "-File", PipelineTestContext.PipelinePath("Validate-OfficialSource.ps1"), - }; - if (release) { arguments.Add("-ReleaseBuild"); } - return PipelineTestContext.Run("pwsh", arguments, environment); - } -} diff --git a/dotnet-package-skills/tests/PipelineTestContext.cs b/dotnet-package-skills/tests/PipelineTestContext.cs deleted file mode 100644 index 1e5d374..0000000 --- a/dotnet-package-skills/tests/PipelineTestContext.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Diagnostics; -using System.Text.Json; - -namespace DotnetPackageSkills.Tests; - -internal static class PipelineTestContext -{ - internal static string RepoRoot { get; } = FindRepoRoot(); - - internal static string DotnetPath { get; } = - File.Exists(Path.Combine(RepoRoot, ".dotnet", "dotnet.exe")) - ? Path.Combine(RepoRoot, ".dotnet", "dotnet.exe") - : Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? - Path.GetFullPath(Path.Combine(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "..", "..", "..", "dotnet.exe")); - - internal static string PipelinePath(string name) => - Path.Combine(RepoRoot, "eng", "pipelines", "dotnet-package-skills", name); - - internal static async Task Evaluate( - string framework, IReadOnlyDictionary? inputs = null, - string project = @"dotnet-package-skills\src\DotnetPackageSkills.csproj") - { - var properties = new Dictionary - { - ["Configuration"] = "Release", - ["TargetFramework"] = framework, - ["ContinuousIntegrationBuild"] = "false", - ["OfficialBuildId"] = "", - ["BUILD_REASON"] = "", - ["BUILD_SOURCEBRANCH"] = "", - ["BUILD_REPOSITORY_PROVIDER"] = "", - ["BUILD_REPOSITORY_NAME"] = "", - ["SYSTEM_TEAMPROJECT"] = "", - ["SYSTEM_COLLECTIONURI"] = "", - }; - if (inputs is not null) - { - foreach (var (key, value) in inputs) { properties[key] = value; } - } - var arguments = new List - { - "msbuild", Path.Combine(RepoRoot, project), "-nologo", "-verbosity:quiet", - "-target:GetAssemblyVersion", - "-getProperty:Version,PackageVersion,VersionPrefix,VersionSuffixDateStamp,VersionSuffixBuildOfTheDay," + - "DotnetPackageSkillsReleaseBuild,DotNetFinalVersionKind,SignAssembly,IsPackable,IsShipping," + - "RuntimeIdentifier,RepoRoot,BaseOutputPath,BaseIntermediateOutputPath,PackageRequireLicenseAcceptance," + - "NuGetPackageRoot,SNVersion", - }; - arguments.AddRange(properties.Select(property => $"-property:{property.Key}={property.Value}")); - return await Run(DotnetPath, arguments); - } - - internal static Dictionary OfficialInputs(bool release = false) => new() - { - ["ContinuousIntegrationBuild"] = "true", - ["OfficialBuildId"] = "20261005.2", - ["DotnetPackageSkillsReleaseBuild"] = release ? "true" : "false", - ["BUILD_REASON"] = "Manual", - ["BUILD_SOURCEBRANCH"] = "refs/heads/main", - ["BUILD_REPOSITORY_PROVIDER"] = "TfsGit", - ["BUILD_REPOSITORY_NAME"] = "NuGet-Client.Tools", - ["SYSTEM_TEAMPROJECT"] = "internal", - ["SYSTEM_COLLECTIONURI"] = "https://dev.azure.com/dnceng/", - }; - - internal static Dictionary Properties(PipelineProcessResult result) - { - Assert.True(result.ExitCode == 0, result.Diagnostics); - using var document = JsonDocument.Parse(result.Output); - return document.RootElement.GetProperty("Properties").EnumerateObject() - .ToDictionary(property => property.Name, property => property.Value.GetString() ?? ""); - } - - internal static async Task Run(string command, IEnumerable arguments, - IReadOnlyDictionary? environment = null) - { - var start = new ProcessStartInfo(command) - { - WorkingDirectory = RepoRoot, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - foreach (var argument in arguments) { start.ArgumentList.Add(argument); } - foreach (var name in new[] { "DotnetPackageSkillsReleaseBuild", "DotNetFinalVersionKind", "OfficialBuild" }) - { - start.Environment.Remove(name); - } - if (environment is not null) - { - foreach (var (name, value) in environment) { start.Environment[name] = value; } - } - - using var process = Process.Start(start) ?? throw new InvalidOperationException($"Could not start {command}."); - var output = process.StandardOutput.ReadToEndAsync(); - var error = process.StandardError.ReadToEndAsync(); - using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - try - { - await process.WaitForExitAsync(timeout.Token); - } - catch (OperationCanceledException) when (timeout.IsCancellationRequested) - { - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(); - throw new TimeoutException($"{command} did not finish."); - } - return new PipelineProcessResult(process.ExitCode, await output, await error); - } - - private static string FindRepoRoot() - { - for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) - { - if (File.Exists(Path.Combine(directory.FullName, "eng", "common", "build.cmd"))) - { - return directory.FullName; - } - } - throw new InvalidOperationException("Could not find the repository's Arcade entry point."); - } -} - -internal sealed record PipelineProcessResult(int ExitCode, string Output, string Error) -{ - internal string Diagnostics => Output + Environment.NewLine + Error; -} diff --git a/dotnet-package-skills/tests/PipelineVersionTests.cs b/dotnet-package-skills/tests/PipelineVersionTests.cs deleted file mode 100644 index 9801971..0000000 --- a/dotnet-package-skills/tests/PipelineVersionTests.cs +++ /dev/null @@ -1,173 +0,0 @@ -namespace DotnetPackageSkills.Tests; - -public class PipelineVersionTests -{ - [Theory] - [InlineData("net8.0", false, "0.1.0-dev")] - [InlineData("net10.0", false, "0.1.0-dev")] - [InlineData("net8.0", true, "0.1.0-ci")] - [InlineData("net10.0", true, "0.1.0-ci")] - public async Task Native_versions_match_local_and_public_builds(string framework, bool ci, string expected) - { - var result = await PipelineTestContext.Evaluate(framework, new Dictionary - { - ["ContinuousIntegrationBuild"] = ci ? "true" : "false", - }); - var properties = PipelineTestContext.Properties(result); - - Assert.Equal(expected, properties["Version"]); - Assert.Equal(expected, properties["PackageVersion"]); - Assert.Equal("false", properties["DotnetPackageSkillsReleaseBuild"]); - Assert.Empty(properties["DotNetFinalVersionKind"]); - } - - [Theory] - [InlineData("net8.0", false)] - [InlineData("net10.0", false)] - [InlineData("net8.0", true)] - [InlineData("net10.0", true)] - public async Task Native_official_versions_use_the_same_inputs_for_assembly_and_package(string framework, bool release) - { - var properties = PipelineTestContext.Properties( - await PipelineTestContext.Evaluate(framework, PipelineTestContext.OfficialInputs(release))); - var expected = release - ? "0.1.0" - : $"0.1.0-beta.{properties["VersionSuffixDateStamp"]}.{properties["VersionSuffixBuildOfTheDay"]}"; - - Assert.Equal(expected, properties["Version"]); - Assert.Equal(expected, properties["PackageVersion"]); - Assert.Equal("26505", properties["VersionSuffixDateStamp"]); - Assert.Equal("2", properties["VersionSuffixBuildOfTheDay"]); - Assert.Equal(release ? "release" : "", properties["DotNetFinalVersionKind"]); - } - - [Theory] - [InlineData("net8.0")] - [InlineData("net10.0")] - public async Task Native_official_revision_changes_the_prerelease(string framework) - { - var inputs = PipelineTestContext.OfficialInputs(); - inputs["OfficialBuildId"] = "20261005.3"; - var properties = PipelineTestContext.Properties(await PipelineTestContext.Evaluate(framework, inputs)); - - Assert.Equal("0.1.0-beta.26505.3", properties["Version"]); - Assert.Equal(properties["Version"], properties["PackageVersion"]); - } - - [Theory] - [InlineData("ContinuousIntegrationBuild", "false")] - [InlineData("OfficialBuildId", "")] - [InlineData("BUILD_REASON", "IndividualCI")] - [InlineData("BUILD_REASON", "PullRequest")] - [InlineData("BUILD_REASON", "")] - [InlineData("BUILD_SOURCEBRANCH", "refs/heads/feature")] - [InlineData("BUILD_SOURCEBRANCH", "")] - [InlineData("BUILD_REPOSITORY_PROVIDER", "GitHub")] - [InlineData("BUILD_REPOSITORY_NAME", "NuGet/Client.Tools")] - [InlineData("SYSTEM_TEAMPROJECT", "public")] - [InlineData("SYSTEM_COLLECTIONURI", "https://dev.azure.com/dnceng-public/")] - public async Task Stable_requests_reject_untrusted_or_missing_inputs(string property, string value) - { - var inputs = PipelineTestContext.OfficialInputs(release: true); - inputs[property] = value; - - var result = await PipelineTestContext.Evaluate("net8.0", inputs); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("dotnet-package-skills", result.Diagnostics); - } - - [Theory] - [InlineData("12345")] - [InlineData("$(Build.BuildId)")] - [InlineData("20261301.1")] - [InlineData("20260230.1")] - [InlineData("20261005.0")] - [InlineData("20261005.100")] - [InlineData("20261005.01")] - [InlineData("20261005.x")] - public async Task Stable_requests_reject_malformed_official_build_numbers(string buildNumber) - { - var inputs = PipelineTestContext.OfficialInputs(release: true); - inputs["OfficialBuildId"] = buildNumber; - - var result = await PipelineTestContext.Evaluate("net10.0", inputs); - - Assert.NotEqual(0, result.ExitCode); - } - - [Theory] - [InlineData("release")] - [InlineData("prerelease")] - public async Task Final_version_cannot_bypass_the_default_false_release_opt_in(string finalKind) - { - var inputs = PipelineTestContext.OfficialInputs(); - inputs["DotNetFinalVersionKind"] = finalKind; - - var result = await PipelineTestContext.Evaluate("net8.0", inputs); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("DotnetPackageSkillsReleaseBuild", result.Diagnostics); - } - - [Theory] - [InlineData("yes")] - [InlineData("$(ReleaseBuild)")] - public async Task Release_opt_in_requires_a_boolean(string value) - { - var inputs = PipelineTestContext.OfficialInputs(); - inputs["DotnetPackageSkillsReleaseBuild"] = value; - - var result = await PipelineTestContext.Evaluate("net8.0", inputs); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("DotnetPackageSkillsReleaseBuild", result.Diagnostics); - } - - [Theory] - [InlineData("RuntimeIdentifier", "win-x64")] - [InlineData("Version", "0.1.0")] - [InlineData("PackageVersion", "0.2.0")] - public async Task Packaging_cannot_override_portability_or_native_version_inputs(string property, string value) - { - var result = await PipelineTestContext.Evaluate("net8.0", new Dictionary - { - [property] = value, - }); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("dotnet-package-skills", result.Diagnostics); - } - - [Theory] - [InlineData("net8.0")] - [InlineData("net10.0")] - public async Task Tool_configuration_preserves_identity_and_native_artifact_paths(string framework) - { - var properties = PipelineTestContext.Properties(await PipelineTestContext.Evaluate(framework)); - - Assert.Equal("false", properties["SignAssembly"]); - Assert.Equal("true", properties["IsPackable"]); - Assert.Equal("true", properties["IsShipping"]); - Assert.Equal("false", properties["PackageRequireLicenseAcceptance"]); - Assert.Empty(properties["RuntimeIdentifier"]); - Assert.Equal(Path.GetFullPath(PipelineTestContext.RepoRoot) + Path.DirectorySeparatorChar, properties["RepoRoot"]); - Assert.Equal(Path.Combine(PipelineTestContext.RepoRoot, "artifacts", "bin", "DotnetPackageSkills") + - Path.DirectorySeparatorChar, properties["BaseOutputPath"]); - Assert.Equal(Path.Combine(PipelineTestContext.RepoRoot, "artifacts", "obj", "DotnetPackageSkills") + - Path.DirectorySeparatorChar, properties["BaseIntermediateOutputPath"]); - } - - [Theory] - [InlineData("net8.0")] - [InlineData("net10.0")] - public async Task Tests_remain_unsigned_nonpackable_and_nonshipping(string framework) - { - var properties = PipelineTestContext.Properties(await PipelineTestContext.Evaluate( - framework, project: @"dotnet-package-skills\tests\DotnetPackageSkills.Tests.csproj")); - - Assert.Equal("false", properties["SignAssembly"]); - Assert.Equal("false", properties["IsPackable"]); - Assert.Equal("false", properties["IsShipping"]); - } -} diff --git a/eng/Versions.props b/eng/Versions.props index e793ad5..6521442 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -3,5 +3,4 @@ 0.1.0 beta - diff --git a/eng/pipelines/dotnet-package-skills/Build.props b/eng/pipelines/dotnet-package-skills/Build.props deleted file mode 100644 index b6ca117..0000000 --- a/eng/pipelines/dotnet-package-skills/Build.props +++ /dev/null @@ -1,27 +0,0 @@ - - - false - <_DotnetPackageSkillsReleaseRequested>$([System.Text.RegularExpressions.Regex]::IsMatch('$(DotnetPackageSkillsReleaseBuild)', '^(?i:true)$')) - <_DotnetPackageSkillsValidBuildId>false - <_DotnetPackageSkillsBuildIdShape>$([System.Text.RegularExpressions.Regex]::IsMatch('$(OfficialBuildId)', '^20[0-9]{2}(0[1-9]|1[0-2])(0[1-9]|[12][0-9]|3[01])\.[1-9][0-9]?$')) - - - <_DotnetPackageSkillsBuildYear>$(OfficialBuildId.Substring(0, 4)) - <_DotnetPackageSkillsBuildMonth>$(OfficialBuildId.Substring(4, 2)) - <_DotnetPackageSkillsBuildDay>$(OfficialBuildId.Substring(6, 2)) - <_DotnetPackageSkillsValidBuildId Condition="$(_DotnetPackageSkillsBuildDay) <= $([System.DateTime]::DaysInMonth($(_DotnetPackageSkillsBuildYear), $(_DotnetPackageSkillsBuildMonth)))">true - - - - false - <_DotnetPackageSkillsTrustedBuild>false - <_DotnetPackageSkillsTrustedBuild Condition="'$(OfficialBuild)' == 'true' and - '$(ContinuousIntegrationBuild)' == 'true' and '$(_DotnetPackageSkillsValidBuildId)' == 'true' and - '$(BUILD_REPOSITORY_PROVIDER)' == 'TfsGit' and '$(BUILD_REPOSITORY_NAME)' == 'NuGet-Client.Tools' and - '$(SYSTEM_TEAMPROJECT)' == 'internal' and '$(SYSTEM_COLLECTIONURI)' == 'https://dev.azure.com/dnceng/' and - '$(BUILD_SOURCEBRANCH)' == 'refs/heads/main' and '$(BUILD_REASON)' != '' and '$(BUILD_REASON)' != 'PullRequest'">true - release - - - diff --git a/eng/pipelines/dotnet-package-skills/Build.targets b/eng/pipelines/dotnet-package-skills/Build.targets deleted file mode 100644 index 2465129..0000000 --- a/eng/pipelines/dotnet-package-skills/Build.targets +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - $(ArtifactsShippingPackagesDir)$(PackageId).$(PackageVersion).nupkg - $(BaseOutputPath)$(Configuration) - $(NuGetPackageRoot)sn\$(SNVersion)\sn.exe - - - - - - <_DotnetPackageSkillsSigningInput Include="$(DotnetPackageSkillsPackagePath)" - PackagePath="$(DotnetPackageSkillsPackagePath)" - PackageVersion="$(PackageVersion)" - BuildOutputPath="$(DotnetPackageSkillsBuildOutputPath)" /> - <_DotnetPackageSkillsSigningInput Include="$(DotnetPackageSkillsBuildOutputPath)\net8.0\dotnet-package-skills.dll" /> - <_DotnetPackageSkillsSigningInput Include="$(DotnetPackageSkillsBuildOutputPath)\net10.0\dotnet-package-skills.dll" /> - - - diff --git a/eng/pipelines/dotnet-package-skills/Invoke-Build.ps1 b/eng/pipelines/dotnet-package-skills/Invoke-Build.ps1 deleted file mode 100644 index 4e419f1..0000000 --- a/eng/pipelines/dotnet-package-skills/Invoke-Build.ps1 +++ /dev/null @@ -1,118 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [ValidateSet('RestoreBuild', 'Test', 'Pack', 'Sign', 'Verify', 'Metadata')] - [string] $Action, - [switch] $CI, - [switch] $Official, - [switch] $ReleaseBuild, - [string] $PackageArtifactPath -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest -Import-Module (Join-Path $PSScriptRoot 'Package-Validation.psm1') -Force -$repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..\..')).Path -Set-Location -LiteralPath $repoRoot - -$properties = @( - "/p:DotnetPackageSkillsReleaseBuild=$($ReleaseBuild.IsPresent.ToString().ToLowerInvariant())", - '/p:NETCORE_ENGINEERING_TELEMETRY=false' -) -$evaluationProperties = @("/p:ContinuousIntegrationBuild=$($CI.IsPresent.ToString().ToLowerInvariant())") -if ($Official) { - & (Join-Path $PSScriptRoot 'Validate-OfficialSource.ps1') -ReleaseBuild:$ReleaseBuild - $properties += @( - "/p:OfficialBuildId=$($env:BUILD_BUILDNUMBER)", - '/p:DotNetSignType=real', - "/p:TeamName=$($env:DOTNET_PACKAGE_SKILLS_SIGNING_TEAM)" - ) -} -else { $properties += '/p:OfficialBuildId=' } -if (-not [string]::IsNullOrWhiteSpace($env:BUILD_SOURCEVERSION)) { - if ($env:BUILD_SOURCEVERSION -cnotmatch '^[0-9a-f]{40}$') { throw 'Build.SourceVersion must be a full source commit SHA.' } - $properties += "/p:SourceRevisionId=$($env:BUILD_SOURCEVERSION)" -} -$evaluationProperties += $properties - -function Get-ArcadeDotnetPath { - $configuration = 'Release' - $prepareMachine = $false - $restore = $false - . (Join-Path $repoRoot 'eng\common\tools.ps1') - $installation = InitializeDotNetCli -install:$false - $dotnet = Join-Path $installation 'dotnet.exe' - if (-not (Test-Path -LiteralPath $dotnet -PathType Leaf)) { throw "Missing Arcade dotnet installation $dotnet." } - return $dotnet -} - -function Get-BuildMetadata { - $dotnet = Get-ArcadeDotnetPath - $arguments = @( - 'msbuild', (Join-Path $repoRoot 'dotnet-package-skills\src\DotnetPackageSkills.csproj'), - '-nologo', '-verbosity:quiet', '-target:GetDotnetPackageSkillsBuildMetadata', - '-property:Configuration=Release', '-property:TargetFramework=net8.0', - '-getProperty:Version,PackageVersion,DotnetPackageSkillsPackagePath,DotnetPackageSkillsBuildOutputPath,DotnetPackageSkillsStrongNameToolPath,ArtifactsTestResultsDir' - ) + $evaluationProperties - $metadata = (Invoke-CheckedCommand $dotnet $arguments | ConvertFrom-Json).Properties - if ($metadata.Version -cne $metadata.PackageVersion -or [string]::IsNullOrWhiteSpace($metadata.PackageVersion)) { - throw 'Arcade did not evaluate a consistent package and assembly version.' - } - $metadata | Add-Member -NotePropertyName DotnetPath -NotePropertyValue $dotnet - return $metadata -} - -function Invoke-ArcadeBuild { - param([string[]] $Actions, [string[]] $AdditionalProperties = @()) - - $arguments = @($Actions) + @('-configuration', 'Release') + $properties + $AdditionalProperties - if ($CI) { $arguments += '-ci' } - & (Join-Path $repoRoot 'eng\common\build.cmd') @arguments - if ($LASTEXITCODE -ne 0) { throw "Arcade $Action failed with exit code $LASTEXITCODE." } -} - -switch ($Action) { - 'RestoreBuild' { Invoke-ArcadeBuild @('-restore', '-build') } - 'Test' { - $start = [datetime]::UtcNow - Invoke-ArcadeBuild @('-test') @('/p:SkipTests=false', '/p:TestRunnerAdditionalArguments=') - $metadata = Get-BuildMetadata - & (Join-Path $PSScriptRoot 'Test-Results.ps1') -ResultsDirectory $metadata.ArtifactsTestResultsDir ` - -NotBeforeUtc $start -MinimumFunctionalTests 792 - } - 'Pack' { - $metadata = Get-BuildMetadata - $start = [datetime]::UtcNow - Invoke-ArcadeBuild @('-pack') @('/p:NoBuild=true') - $package = $metadata.DotnetPackageSkillsPackagePath - if (-not (Test-Path -LiteralPath $package -PathType Leaf) -or - (Get-Item -LiteralPath $package).Length -eq 0 -or - (Get-Item -LiteralPath $package).LastWriteTimeUtc -lt $start) { - throw "Arcade did not produce the exact current package $package." - } - $packages = @(Get-ChildItem -LiteralPath (Split-Path -Parent $package) -Filter 'dotnet-package-skills.*.nupkg') - if ($packages.Count -ne 1 -or $packages[0].FullName -cne $package) { - throw 'Unexpected dotnet-package-skills shipping package output; remove only the named stale tool packages before switching build kinds.' - } - } - 'Sign' { Invoke-ArcadeBuild @('-sign') } - 'Verify' { - $metadata = Get-BuildMetadata - $parameters = @{ - PackagePath = $metadata.DotnetPackageSkillsPackagePath - ExpectedVersion = $metadata.PackageVersion - BuildOutputPath = $metadata.DotnetPackageSkillsBuildOutputPath - DotnetPath = $metadata.DotnetPath - StrongNameToolPath = $metadata.DotnetPackageSkillsStrongNameToolPath - RequireSigned = $Official.IsPresent - } - if (-not [string]::IsNullOrWhiteSpace($env:BUILD_SOURCEVERSION)) { $parameters.ExpectedCommit = $env:BUILD_SOURCEVERSION } - & (Join-Path $PSScriptRoot 'Verify-Package.ps1') @parameters - if ($PackageArtifactPath -ne '') { - [IO.Directory]::CreateDirectory($PackageArtifactPath) | Out-Null - Copy-Item -LiteralPath $metadata.DotnetPackageSkillsPackagePath -Destination $PackageArtifactPath - } - if ($CI) { Write-Host '##vso[task.setvariable variable=DotnetPackageSkillsPackageVerified]true' } - } - 'Metadata' { Get-BuildMetadata | ConvertTo-Json } -} diff --git a/eng/pipelines/dotnet-package-skills/Package-Validation.psm1 b/eng/pipelines/dotnet-package-skills/Package-Validation.psm1 deleted file mode 100644 index d99a985..0000000 --- a/eng/pipelines/dotnet-package-skills/Package-Validation.psm1 +++ /dev/null @@ -1,209 +0,0 @@ -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -function Invoke-CheckedCommand { - param([string] $Command, [string[]] $Arguments) - - $output = & $Command @Arguments 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "'$Command' failed with exit code ${LASTEXITCODE}:`n$($output -join "`n")" - } - return $output -join "`n" -} - -function Read-ArchiveText { - param([IO.Compression.ZipArchive] $Archive, [string] $Name) - - $entry = $Archive.GetEntry($Name) - if ($null -eq $entry) { throw "Package is missing $Name." } - if ($entry.Length -eq 0) { throw "Package contains empty $Name." } - $reader = [IO.StreamReader]::new($entry.Open()) - try { return $reader.ReadToEnd() } - finally { $reader.Dispose() } -} - -function Get-MetadataElement { - param([xml] $Nuspec, [string] $Name, [switch] $Optional) - - $nodes = @($Nuspec.SelectNodes("/*[local-name()='package']/*[local-name()='metadata']/*[local-name()='$Name']")) - if ($Optional -and $nodes.Count -eq 0) { return $null } - if ($nodes.Count -ne 1) { throw "Package metadata must contain one $Name element." } - return $nodes[0] -} - -function Test-DotnetPackageSkillsPackage { - [CmdletBinding()] - param( - [Parameter(Mandatory)][string] $PackagePath, - [Parameter(Mandatory)][string] $ExpectedVersion, - [Parameter(Mandatory)][string] $BuildOutputPath, - [Parameter(Mandatory)][string] $DotnetPath, - [Parameter(Mandatory)][string] $StrongNameToolPath, - [string] $ExpectedCommit, - [switch] $RequireSigned - ) - - $package = (Resolve-Path -LiteralPath $PackagePath).Path - $buildOutput = (Resolve-Path -LiteralPath $BuildOutputPath).Path - if (-not (Test-Path -LiteralPath $StrongNameToolPath -PathType Leaf)) { - throw "Missing Arcade strong-name validation tool $StrongNameToolPath." - } - [xml] $policy = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'Payload.props') -Raw - $expectedPayload = [Collections.Generic.Dictionary[string, Xml.XmlElement]]::new([StringComparer]::Ordinal) - foreach ($item in $policy.Project.ItemGroup.FileSignInfo) { - $owner = $item.GetAttribute('Owner') - $certificate = $item.GetAttribute('CertificateName') - if (($owner -eq 'ThirdParty' -and $certificate -cne '3PartySHA2') -or - ($owner -in @('Microsoft', 'Tool') -and $certificate -cne 'MicrosoftDotNet500') -or - $owner -notin @('Microsoft', 'Tool', 'ThirdParty')) { - throw "Incorrect signing policy for $($item.GetAttribute('Include'))." - } - $cultures = @('') - if ($item.GetAttribute('Cultures') -ne '') { $cultures = $item.GetAttribute('Cultures').Split(';') } - foreach ($framework in $item.GetAttribute('Frameworks').Split(';')) { - foreach ($culture in $cultures) { - $relative = $item.GetAttribute('Include') - if ($culture -ne '') { $relative = "$culture/$relative" } - $expectedPayload.Add("tools/$framework/any/$relative", $item) - } - } - } - if ($expectedPayload.Count -eq 0) { throw 'The signing payload registry is empty.' } - - $scratch = [IO.Directory]::CreateTempSubdirectory('dotnet-package-skills-payload-').FullName - $archive = $null - try { - $archive = [IO.Compression.ZipFile]::OpenRead($package) - $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) - $payload = [Collections.Generic.Dictionary[string, IO.Compression.ZipArchiveEntry]]::new([StringComparer]::Ordinal) - foreach ($entry in $archive.Entries) { - $name = $entry.FullName - if ([string]::IsNullOrWhiteSpace($name) -or [IO.Path]::IsPathRooted($name) -or - $name.Contains('\') -or $name.Contains(':') -or - @($name.Split('/') | Where-Object { $_ -in @('.', '..') }).Count -ne 0) { - throw "Invalid package archive path $name." - } - if (-not $seen.Add($name)) { throw "Package contains duplicate archive entry $name." } - if ($name.StartsWith('tools/', [StringComparison]::Ordinal) -and - $name -cnotmatch '^tools/(net8\.0|net10\.0)/any/') { - throw "Nonportable or unexpected tool payload $name." - } - $stream = $entry.Open() - try { $isPE = $stream.ReadByte() -eq 0x4d -and $stream.ReadByte() -eq 0x5a } - finally { $stream.Dispose() } - if ($isPE -or $name -match '\.(dll|exe)$') { - if (-not $expectedPayload.ContainsKey($name)) { throw "Unmapped package payload $name." } - if ($entry.Length -eq 0) { throw "Package contains empty payload $name." } - $payload.Add($name, $entry) - } - } - foreach ($name in $expectedPayload.Keys) { - if (-not $payload.ContainsKey($name)) { throw "Package is missing declared payload $name." } - } - - [xml] $nuspec = Read-ArchiveText $archive 'dotnet-package-skills.nuspec' - if ((Get-MetadataElement $nuspec 'id').InnerText -cne 'dotnet-package-skills') { - throw 'Package ID must be dotnet-package-skills.' - } - $version = (Get-MetadataElement $nuspec 'version').InnerText - if ($version -cne $ExpectedVersion) { throw "Package version '$version' does not match '$ExpectedVersion'." } - if ((Get-MetadataElement $nuspec 'authors').InnerText -cne 'dotnet-package-skills contributors') { - throw 'Package authors must remain dotnet-package-skills contributors.' - } - $types = @((Get-MetadataElement $nuspec 'packageTypes').ChildNodes | Where-Object LocalName -eq 'packageType') - if ($types.Count -ne 1 -or $types[0].GetAttribute('name') -cne 'DotnetTool') { - throw 'Package must declare only the DotnetTool package type.' - } - $license = Get-MetadataElement $nuspec 'license' - if ($license.GetAttribute('type') -cne 'expression' -or $license.InnerText -cne 'MIT') { - throw 'Package must retain its MIT license expression.' - } - $acceptance = Get-MetadataElement $nuspec 'requireLicenseAcceptance' -Optional - if ($null -ne $acceptance -and $acceptance.InnerText -cne 'false') { - throw 'Package license acceptance must remain false.' - } - $origin = Get-MetadataElement $nuspec 'repository' - if ($origin.GetAttribute('type') -cne 'git' -or - $origin.GetAttribute('url') -cne 'https://github.com/NuGet/Client.Tools' -or - $origin.GetAttribute('commit') -cnotmatch '^[0-9a-f]{40}$' -or - ($ExpectedCommit -ne '' -and $origin.GetAttribute('commit') -cne $ExpectedCommit)) { - throw 'Package origin must identify the expected NuGet/Client.Tools source commit.' - } - if ((Get-MetadataElement $nuspec 'readme').InnerText -cne 'README.md' -or - [string]::IsNullOrWhiteSpace((Read-ArchiveText $archive 'README.md'))) { - throw 'Package README must not be empty.' - } - $signature = $archive.GetEntry('.signature.p7s') - if ($RequireSigned -and $null -eq $signature) { - throw 'Package is not signed; official artifacts must have a NuGet signature.' - } - if (-not $RequireSigned -and $null -ne $signature) { throw 'Public and local packages must be unsigned.' } - if ($RequireSigned) { Write-Host (Invoke-CheckedCommand $DotnetPath @('nuget', 'verify', $package, '--all')) } - - foreach ($framework in @('net8.0', 'net10.0')) { - [xml] $settings = Read-ArchiveText $archive "tools/$framework/any/DotnetToolSettings.xml" - $commands = @($settings.DotNetCliTool.Commands.Command) - if ($commands.Count -ne 1 -or $commands[0].Name -cne 'dotnet-package-skills' -or - $commands[0].EntryPoint -cne 'dotnet-package-skills.dll' -or $commands[0].Runner -cne 'dotnet') { - throw "Incorrect tool command settings for $framework." - } - $runtime = Read-ArchiveText $archive "tools/$framework/any/dotnet-package-skills.runtimeconfig.json" | ConvertFrom-Json - if ($runtime.runtimeOptions.tfm -cne $framework -or - $runtime.runtimeOptions.framework.name -cne 'Microsoft.NETCore.App') { - throw "Incorrect runtime configuration for $framework." - } - $null = Read-ArchiveText $archive "tools/$framework/any/dotnet-package-skills.deps.json" | ConvertFrom-Json - } - - foreach ($name in $payload.Keys) { - $item = $expectedPayload[$name] - $parts = $name.Split('/') - $relative = $parts[3..($parts.Length - 1)] -join [IO.Path]::DirectorySeparatorChar - $original = Join-Path (Join-Path $buildOutput $parts[1]) $relative - if (-not (Test-Path -LiteralPath $original -PathType Leaf)) { throw "Missing built payload $original." } - $extracted = Join-Path $scratch ($name.Replace('/', [IO.Path]::DirectorySeparatorChar)) - [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($extracted)) | Out-Null - [IO.Compression.ZipFileExtensions]::ExtractToFile($payload[$name], $extracted) - $packageHash = (Get-FileHash -LiteralPath $extracted -Algorithm SHA256).Hash - $originalHash = (Get-FileHash -LiteralPath $original -Algorithm SHA256).Hash - if ($item.GetAttribute('Owner') -eq 'Tool' -and $packageHash -cne $originalHash) { - throw "The $($parts[1]) package payload does not match the tested built assembly." - } - $identity = [Reflection.AssemblyName]::GetAssemblyName($extracted) - $originalIdentity = [Reflection.AssemblyName]::GetAssemblyName($original) - $token = [Convert]::ToHexString($identity.GetPublicKeyToken()).ToLowerInvariant() - if ($token -cne $item.GetAttribute('PublicKeyToken') -or - $identity.FullName -cne $originalIdentity.FullName -or - ($identity.GetPublicKey() -join ',') -cne ($originalIdentity.GetPublicKey() -join ',')) { - throw "Strong-name assembly identity changed for payload $name." - } - if ($token -ne '') { - $null = Invoke-CheckedCommand $StrongNameToolPath @('-q', '-vf', $original) - $null = Invoke-CheckedCommand $StrongNameToolPath @('-q', '-vf', $extracted) - } - if ($item.GetAttribute('OriginalSignature') -eq 'true') { - if ($packageHash -cne $originalHash) { throw "Original signed dependency changed for payload $name." } - if ((Get-AuthenticodeSignature -LiteralPath $original).Status -ne 'Valid') { - throw "Original dependency signature is not valid for $name." - } - } - if ($RequireSigned) { - $authenticode = Get-AuthenticodeSignature -LiteralPath $extracted - if ($authenticode.Status -ne 'Valid') { - throw "Extracted payload signature is not valid for ${name}: $($authenticode.StatusMessage)" - } - if ($item.GetAttribute('Owner') -in @('Microsoft', 'Tool') -and - $authenticode.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') { - throw "Extracted first-party payload has an unexpected signing identity: $name." - } - } - } - } - finally { - if ($null -ne $archive) { $archive.Dispose() } - Remove-Item -LiteralPath $scratch -Recurse -Force - } - Write-Host "Verified package metadata, $($expectedPayload.Count) PE payloads, original signatures, and strong-name identities for $ExpectedVersion." -} - -Export-ModuleMember -Function Invoke-CheckedCommand, Test-DotnetPackageSkillsPackage diff --git a/eng/pipelines/dotnet-package-skills/Payload.props b/eng/pipelines/dotnet-package-skills/Payload.props deleted file mode 100644 index 8d08301..0000000 --- a/eng/pipelines/dotnet-package-skills/Payload.props +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - diff --git a/eng/pipelines/dotnet-package-skills/README.md b/eng/pipelines/dotnet-package-skills/README.md index 5d6e322..39d7377 100644 --- a/eng/pipelines/dotnet-package-skills/README.md +++ b/eng/pipelines/dotnet-package-skills/README.md @@ -1,214 +1,69 @@ # dotnet-package-skills pipeline -`stage.yml` contributes one authored `DotnetPackageSkills` stage. The tool's pipeline code -lives in this folder. The root Arcade graph includes the tool solution and -`eng\Infrastructure.proj`. The stage runs that infrastructure check as part of its build. -It does not add a second smoke job. There are no Python, Node, Linux, or macOS CI workloads. - -| Entry point | Source | Output | -| --- | --- | --- | -| `eng\pipelines\pr.yml` | GitHub, PRs targeting `main`, in `dnceng-public/public` | Unsigned package, both framework results, logs | -| `eng\pipelines\official.yml` | Trusted `dnceng/internal/NuGet-Client.Tools` Azure Repos mirror, `main`, non-PR | Real-signed package and 1ES-governed artifacts | - -The main-only PR filter belongs to the shared foundation. Keep it. A PR stacked above the -onboarding branch might not trigger Azure Pipelines. Queue public definition 351, -`client.tools-ci`, for the exact pushed upper commit when needed. A successful infrastructure -run for the lower PR does not validate the tool. - -## Build and artifact contract - -Run every build action from the repository root through `eng\common\build.cmd`. -The root `global.json`, `NuGet.Config`, and `eng\Versions.props` are shared inputs. -The tool imports the root Arcade props and keeps its scoped compiler settings. -Do not add a tool-local SDK pin or restore configuration. -The root MIT file uses Arcade's required heading and reserved-rights line. Its NuGet copyright -and MIT permission grant stay unchanged; repository license validation remains enabled. - -The tool keeps `SignAssembly=false`, `IsPackable=true`, and `IsShipping=true`. -Tests stay unsigned, non-packable, and non-shipping. The infrastructure project stays -non-packable and non-shipping. Keep the package and command name `dotnet-package-skills`, -the existing authors, MIT license, and false license-acceptance setting. Keep both -`tools/net8.0/any` and `tools/net10.0/any`. Do not set a runtime identifier. - -| Content | Native path | -| --- | --- | -| Tool binaries | `artifacts\bin\DotnetPackageSkills\Release\` | -| Intermediate files | `artifacts\obj\DotnetPackageSkills` | -| Build and signing logs | `artifacts\log\Release` | -| Both VSTest result files | `artifacts\TestResults\Release` | -| Shipping package | `artifacts\packages\Release\Shipping` | - -The pipeline installs the root-pinned SDK and the .NET 8 runtime into the same `.dotnet` -installation. It sets `DOTNET_INSTALL_DIR` so Arcade selects that installation. Local -verification uses Arcade's own `InitializeDotNetCli` selection. A runtime installed beside -an unrelated PATH `dotnet` does not satisfy this contract. - -The order is: - -1. Restore and build the root graph. -2. Run C# tests on both frameworks. -3. Pack without rebuilding. -4. Validate the signing inputs. Official builds recursively sign the innermost DLLs, - repack the package, and sign its NuGet container. Public builds use a dry run. -5. Verify the final package and install that exact package on each runtime. -6. Publish the verified package. Keep logs and results on failure. - -Do not build or pack after signing. There is no intermediate `obj` signing workaround. -Arcade's VSTest runner executes the xUnit suite. Supported version properties retain xUnit -2.9.3, its original analyzer version, VS runner 3.1.4, and Test SDK 17.14.1. Coverlet stays -6.0.4. Test publication fails on failed or missing files. The separate results check also -requires fresh, complete, successful files for both frameworks and preserves the 792 -application tests that remain after retiring the 15 custom-version-script cases. - -## Native versions - -`eng\Versions.props` holds the reviewed `VersionPrefix=0.1.0` and -`PreReleaseVersionLabel=beta`. Arcade computes the versions. There is no custom date or -package-version calculator. - -| Build kind | Package version | +The root public and official entry points share one `DotnetPackageSkills` stage in `stage.yml`. +Each Windows job installs the root-pinned SDK and the .NET 8 runtime into the same Arcade +installation, then runs one command from the repository root: + +```powershell +eng\common\build.cmd -restore -build -test -pack -configuration Release -ci +``` + +The official job adds `-sign`, `DotNetSignType=real`, `OfficialBuildId=$(Build.BuildNumber)`, +and the approved `TeamName`. Arcade packs first, signs the nested assemblies, repacks, and +signs the NuGet package. Do not build or pack again after signing. The public job does not +invoke signing or use production resources. + +Arcade retains infrastructure validation, runs the original application tests on net8.0 and +net10.0, publishes VSTest results, and keeps diagnostic logs. The successful NuGet package is +published as `dotnet-package-skills-packages`. Official outputs use the inherited 1ES wrapper. +There are no custom pipeline validators, package smoke scripts, or pipeline-only test suites. +Nothing is published to feeds, BAR/Maestro, symbol servers, or GitHub releases. + +## Versions and outputs + +The reviewed base and prerelease label live in root `eng\Versions.props`. Arcade computes: + +| Build | Version | | --- | --- | | Local | `0.1.0-dev` | | Public CI or PR | `0.1.0-ci` | -| Ordinary official | `0.1.0-beta..` | -| Explicit manual release from trusted main | `0.1.0` | - -The fixed public CI suffix is deliberate. These packages are artifacts, not immutable feed -publications. The official pipeline name is `$(Date:yyyyMMdd).$(Rev:r)`. Pass that build number -as `OfficialBuildId`, not Azure's numeric `Build.BuildId`. The guard requires a real date and -a revision from 1 through 99 without leading zeros. Use the same inputs for build, test, pack, -sign, and verification. - -`DotnetPackageSkillsReleaseBuild` defaults to false. After reviewing the base version and -confirming trusted-mirror parity, manually queue the official main definition with this option -set to true. Only this request maps to `DotNetFinalVersionKind=release`. Public, PR, automatic, -non-main, missing, and malformed release requests fail. A direct final-kind or package-version -override cannot bypass the guard. Builds do not create version commits, tags, or releases. - -`Invoke-Build.ps1 -Action Metadata` runs the native MSBuild evaluation with matching inputs. -The wrapper uses the evaluated `Version`, `PackageVersion`, package path, binary path, and -strong-name tool path. It never selects a package by a stale glob. Pack also rejects an -unexpected second tool package. Before changing build kinds locally, remove only the -specific old tool package that you generated, not a broad artifacts directory. - -## Arcade signing and owner prerequisites - -The reference is how -[dotnet/sign builds its own releases](https://github.com/dotnet/sign/blob/f3cc758ffd4f5edccb567820606b17eea49745c0/.vsts-ci.yml), -not the public `sign` CLI as a production signer. The official job uses Arcade's -`templates-official` wrapper with `enableMicrobuild=true`, `_SignType=real`, and -`DotNetSignType=real`. The inherited `MicroBuildSigningPlugin@4` and ESRP integration supply -the DNCEng shared feed and connections. Do not replace them with direct `EsrpCodeSigning@6` -steps, invent connection values, or create credentials. - -The stage includes signing resources only when template expansion identifies all of these: -TfsGit, repository `NuGet-Client.Tools`, project `internal`, collection -`https://dev.azure.com/dnceng/`, main, and a nonempty non-PR reason. Any other official source -expands to a rejecting 1ES job with `runAsPublic=true` and MicroBuild disabled. A runtime -source guard repeats the checks before plugin installation. The public entry point has no -1ES repository or production signing connection. - -The owner must configure `DotnetPackageSkillsSigningTeamName` with the approved team -identifier. The job maps this one non-secret setting to `_TeamName` and `TeamName`. -Missing or unresolved values fail. The old eight custom ESRP settings are retired. +| Official | `0.1.0-beta..` | +| Manual official stable opt-in | `0.1.0` | -Before a real-signing run, the owner must confirm: +The official build number uses `yyyyMMdd.r`, not Azure's numeric BuildId. +`DotnetPackageSkillsReleaseBuild` defaults to false. Only a manual trusted-main run can set it +to true; that sets the native `DotNetFinalVersionKind=release` property. -1. The internal definition uses the trusted mirror and has the reviewed source commit. -2. The internal pool and 1ES template repository are authorized. -3. The MicroBuild v4 extension, MicroBuildToolset feed, inherited shared connections, team, - and signing profiles are authorized for this definition. -4. The required signing approvals and branch controls are satisfied. +Packages are under `artifacts\packages\Release\Shipping`; binaries are under +`artifacts\bin\DotnetPackageSkills\Release\`. The package remains a portable +`tools//any` tool for both frameworks. Only the product is packable and shipping. +The product and tests keep `SignAssembly=false`; dependencies keep their strong-name identities. -Do not authorize every pipeline as a shortcut. Do not grant permissions as part of this -migration. Extension installation alone does not prove connection or profile authorization. -The first successful authorized official run remains an acceptance gate. +## Official signing prerequisites -### Signing registry and payload policy +The official entry point includes MicroBuild resources only for non-PR main builds from +TfsGit `NuGet-Client.Tools` in `dnceng/internal`. Untrusted source plans reject the run without +including those signing resources. Keep the inherited 1ES governance and main-only PR filter. -Root `eng\Signing.props` is a thin registry. It imports this folder's signing configuration. -The configuration removes the broad automatic artifact list and evaluates exactly three -inputs: this build's shipping `.nupkg` and the tested loose owned DLL for each framework. -Missing, empty, or unmapped inputs fail. +The owner must provide the approved `DotnetPackageSkillsSigningTeamName` and authorize the +existing internal pool, 1ES templates, MicroBuild feed/shared connections, and signing profiles. +No credentials or permissions are created by this pipeline. The trusted mirror must contain +the reviewed main commit before a real-signing run. -`Payload.props` is the reviewed inventory. It covers all 33 shipped PE entries: +Root `eng\Signing.props` imports the concise module `Signing.props`. It lists the tool package +and both loose owned DLLs. Arcade uses the .NET certificate for first-party DLLs and the +standard NuGet profile for the package. SharpYaml uses `3PartySHA2`. Already signed Microsoft +dependencies keep their original signatures. This follows how +[dotnet/sign builds its own releases](https://github.com/dotnet/sign/blob/f3cc758ffd4f5edccb567820606b17eea49745c0/.vsts-ci.yml), +not the public sign CLI. Real-signing acceptance requires a successful authorized official run; +local or public builds do not prove it. -| DLL | Ownership and policy | -| --- | --- | -| `dotnet-package-skills.dll` | Tool-owned, `MicrosoftDotNet500`, still not strong-named | -| `SharpYaml.dll` | Third-party, `3PartySHA2`; retain its existing strong name | -| `System.Collections.Immutable.dll` | .NET, net8.0 only; retain its original Microsoft signature and strong name | -| `System.CommandLine.dll` | Microsoft; retain its original signature and strong name | -| `System.CommandLine.resources.dll` | All 13 shipped cultures on both TFMs; retain their original signatures and strong names | -| Outer `.nupkg` | Standard `NuGet` profile | - -The dependency versions stay SharpYaml 3.13.1 and System.CommandLine 2.0.10. The -net8.0 payload also contains System.Collections.Immutable 9.0.0. Do not assign a Microsoft -product certificate to a third-party DLL. Do not suppress the third-party checks or add -blanket ignore rules. No new strong-name key is assigned. - -The pinned SignTool deduplicates identical content and updates the explicitly listed loose -copies. This retains the strict SHA256 comparison between each packaged owned DLL and its -tested loose copy. The preflight and final verifier check redistributed full assembly/key -identities and validate their strong-name signatures with Arcade's `sn.exe`. Existing -Microsoft dependency signatures must remain byte-for-byte unchanged. - -`Verify-Package.ps1 -RequireSigned` checks the actual NuGet signature with -`dotnet nuget verify --all` and validates every extracted owned and dependency DLL signature. -It checks identity, version, MIT, authors, origin commit, README, tool settings, portable -layout, dependency inventory, and owned DLL hashes. It rejects duplicate or unsafe ZIP paths. -The isolated installation uses only the exact package in a local-only feed, separate NuGet -cache and CLI home, and separate tool paths. It runs `--version`, `--help`, `list`, `install`, -and `uninstall` on each runtime and preserves a handwritten fixture skill. - -A successful dry run proves the signing plan, not production signatures or authorization. - -## Local commands and artifacts - -From the repository root: +## Local build and retirement -```powershell -eng\common\build.cmd -restore -build -test -configuration Release -eng\common\build.cmd -pack -configuration Release /p:NoBuild=true -eng\common\build.cmd -sign -configuration Release /p:NETCORE_ENGINEERING_TELEMETRY=false -eng\pipelines\dotnet-package-skills\Invoke-Build.ps1 -Action Metadata -eng\pipelines\dotnet-package-skills\Invoke-Build.ps1 -Action Verify -``` +From the root, run `eng\common\build.cmd -restore -build -test -pack -configuration Release`. +Install the .NET 8 runtime beside the SDK that Arcade selects. Never use `-prepareMachine` +on a shared local machine. -To run the same public-CI actions locally, pass `-CI` to each wrapper action: -`RestoreBuild`, `Test`, `Pack`, `Sign`, then `Verify`. Do not mix local and CI inputs. -Never use `-prepareMachine` on a shared local machine. Do not run broad clean commands. - -Artifacts are named `dotnet-package-skills-packages`, `dotnet-package-skills-testresults`, -and `dotnet-package-skills-logs`. The wrapper stages only the verified exact package under -`$(Build.ArtifactStagingDirectory)\dotnet-package-skills\packages`. Official outputs use 1ES -`templateContext.outputs`; public outputs use ordinary artifact tasks. The shared Arcade -wrapper also retains its diagnostic log artifact. No feeds, BAR/Maestro assets, symbols, -GitHub releases, or tags are published. - -The C# tests expand the owned templates for public, trusted, and rejected source cases. -They do not substitute for Azure's server expansion or the authorized official 1ES run. - -## Retirement when the tool moves into the .NET SDK - -Perform this work in a separate reviewed change. Validate removal in an isolated fixture -first, not by deleting the active product checkout: - -1. Remove the tool solution from `eng\Build.props`. Keep `eng\Infrastructure.proj` and any - other tool entries. -2. Remove this module's import from `eng\Versions.props` and `eng\Signing.props`. Keep the - reviewed base-version properties and other signing modules. If the signing registry is - empty, remove that registry file. -3. Remove the stage-template references and `DotnetPackageSkillsReleaseBuild` parameter - from the root entry points. Retain shared pools, main-only triggers, and the 1ES wrapper. - If this is the only workload, replace its reference with infrastructure validation or - another active workload. Do not leave an invalid empty stage list. -4. Remove the `dotnet-package-skills` source folder and this pipeline folder. Remove the - tool's root README entry. Do not restore old nested sources, samples, or deleted docs. -5. Run the root Arcade restore/build and check that the infrastructure and other tools - remain valid. Keep root SDK/feed files, Arcade imports, and `eng\common` unchanged. -6. Have the owner retire tool-specific definition settings and authorizations where - appropriate. Keep shared pools, connections, the trusted mirror, and 1ES governance. - -The retirement fixture checks both the infrastructure project and a separate future-tool -entry so removing this module cannot remove the shared foundation. +To retire the tool, remove its solution entry, root signing import, stage references, and +source/pipeline folders in a reviewed change. Keep the shared SDK, feeds, Arcade imports, +`eng\Infrastructure.proj`, other tools, and `eng\common` intact. diff --git a/eng/pipelines/dotnet-package-skills/Signing.props b/eng/pipelines/dotnet-package-skills/Signing.props index 24965e0..508708a 100644 --- a/eng/pipelines/dotnet-package-skills/Signing.props +++ b/eng/pipelines/dotnet-package-skills/Signing.props @@ -1,41 +1,14 @@ true - false - false - - - + + + + - + - - - - - - - - - - - - - - - - - - - diff --git a/eng/pipelines/dotnet-package-skills/Test-Package.ps1 b/eng/pipelines/dotnet-package-skills/Test-Package.ps1 deleted file mode 100644 index 2186eeb..0000000 --- a/eng/pipelines/dotnet-package-skills/Test-Package.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)][string] $PackagePath, - [Parameter(Mandatory)][string] $ExpectedVersion, - [Parameter(Mandatory)][string] $BuildOutputPath, - [Parameter(Mandatory)][string] $DotnetPath, - [Parameter(Mandatory)][string] $StrongNameToolPath, - [string] $ExpectedCommit, - [switch] $RequireSigned -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest -Import-Module (Join-Path $PSScriptRoot 'Package-Validation.psm1') -Force -Test-DotnetPackageSkillsPackage @PSBoundParameters diff --git a/eng/pipelines/dotnet-package-skills/Test-Results.ps1 b/eng/pipelines/dotnet-package-skills/Test-Results.ps1 deleted file mode 100644 index f6388eb..0000000 --- a/eng/pipelines/dotnet-package-skills/Test-Results.ps1 +++ /dev/null @@ -1,51 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)][string] $ResultsDirectory, - [datetime] $NotBeforeUtc = [datetime]::MinValue, - [ValidateRange(0, [int]::MaxValue)][int] $MinimumFunctionalTests = 0 -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -foreach ($framework in @('net8.0', 'net10.0')) { - $path = Join-Path $ResultsDirectory "DotnetPackageSkills.Tests_${framework}_x64.trx" - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing required $framework test results: $path." } - if ((Get-Item -LiteralPath $path).LastWriteTimeUtc -lt $NotBeforeUtc.ToUniversalTime()) { - throw "Stale $framework test results cannot validate the current run." - } - [xml] $results = Get-Content -LiteralPath $path -Raw - $summary = $results.SelectSingleNode("/*[local-name()='TestRun']/*[local-name()='ResultSummary']") - if ($null -eq $summary) { throw "Missing $framework results summary." } - $counters = $summary.SelectSingleNode("*[local-name()='Counters']") - if ($null -eq $counters) { throw "Missing $framework results counters." } - $counts = @{} - foreach ($name in @('total', 'executed', 'passed', 'failed')) { - $value = $counters.GetAttribute($name) - if ($value -cnotmatch '^[0-9]+$') { throw "Malformed $framework results counter $name." } - $counts[$name] = [long]::Parse($value, [Globalization.CultureInfo]::InvariantCulture) - } - foreach ($attribute in $counters.Attributes) { - if ($attribute.Name -in @('error', 'timeout', 'aborted', 'inconclusive', 'notRunnable', 'notExecuted', 'disconnected', 'pending', 'inProgress') -and - $attribute.Value -cne '0') { - throw "Unsuccessful $framework results counter $($attribute.Name)." - } - } - $tests = @($results.SelectNodes("/*[local-name()='TestRun']/*[local-name()='Results']/*[local-name()='UnitTestResult']")) - if ($summary.GetAttribute('outcome') -cne 'Completed' -or - $counts.total -le 0 -or $counts.executed -ne $counts.total -or - $counts.passed -ne $counts.total -or $counts.failed -ne 0 -or - $tests.Count -ne $counts.total -or - @($tests | Where-Object { $_.GetAttribute('outcome') -cne 'Passed' }).Count -ne 0) { - throw "Empty, failed, or incomplete $framework test results." - } - $functional = @($tests | Where-Object { - $name = $_.GetAttribute('testName') - $name.StartsWith('DotnetPackageSkills.Tests.', [StringComparison]::Ordinal) -and - -not $name.StartsWith('DotnetPackageSkills.Tests.Pipeline', [StringComparison]::Ordinal) - }).Count - if ($functional -lt $MinimumFunctionalTests) { - throw "The $framework results contain $functional functional tests; at least $MinimumFunctionalTests are required." - } - Write-Host "$framework results: $($counts.passed) passed, $functional functional tests." -} diff --git a/eng/pipelines/dotnet-package-skills/Test-Retirement.ps1 b/eng/pipelines/dotnet-package-skills/Test-Retirement.ps1 deleted file mode 100644 index 57743c0..0000000 --- a/eng/pipelines/dotnet-package-skills/Test-Retirement.ps1 +++ /dev/null @@ -1,153 +0,0 @@ -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest -$source = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..\..')).Path -$fixture = [IO.Directory]::CreateTempSubdirectory('client-tools-retirement-fixture-').FullName -$originalLocation = Get-Location - -try { - foreach ($file in @('global.json', 'NuGet.Config', 'Directory.Build.props', 'Directory.Build.targets', 'build.cmd', 'LICENSE')) { - Copy-Item -LiteralPath (Join-Path $source $file) -Destination $fixture - } - Copy-Item -LiteralPath (Join-Path $source 'eng') -Destination $fixture -Recurse - Copy-Item -LiteralPath (Join-Path $source 'dotnet-package-skills') -Destination $fixture -Recurse - $future = Join-Path $fixture 'eng\FutureTool.proj' - [IO.File]::WriteAllText($future, @' - - - net10.0 - false - false - - - - - - -'@) - $futureSigning = Join-Path $fixture 'eng\pipelines\future-tool' - [IO.Directory]::CreateDirectory($futureSigning) | Out-Null - [IO.File]::WriteAllText((Join-Path $futureSigning 'Signing.props'), '') - - [xml] $build = Get-Content -LiteralPath (Join-Path $fixture 'eng\Build.props') -Raw - $entry = $build.CreateElement('ProjectToBuild') - $entry.SetAttribute('Include', '$(RepoRoot)eng\FutureTool.proj') - $null = $build.Project.ItemGroup.AppendChild($entry) - $build.Save((Join-Path $fixture 'eng\Build.props')) - [xml] $signing = Get-Content -LiteralPath (Join-Path $fixture 'eng\Signing.props') -Raw - $entry = $signing.CreateElement('Import') - $entry.SetAttribute('Project', 'pipelines\future-tool\Signing.props') - $null = $signing.Project.AppendChild($entry) - $signing.Save((Join-Path $fixture 'eng\Signing.props')) - - foreach ($file in @('Build.props', 'Versions.props', 'Signing.props')) { - $path = Join-Path $fixture "eng\$file" - [xml] $document = Get-Content -LiteralPath $path -Raw - foreach ($element in @($document.SelectNodes('//*[@Include or @Project]'))) { - if ($element.GetAttribute('Include').Contains('dotnet-package-skills') -or - $element.GetAttribute('Project').Contains('dotnet-package-skills')) { - $null = $element.ParentNode.RemoveChild($element) - } - } - $document.Save($path) - if ($document.OuterXml.Contains('dotnet-package-skills')) { throw "Retirement left a tool reference in $file." } - } - [xml] $versions = Get-Content -LiteralPath (Join-Path $fixture 'eng\Versions.props') -Raw - if ($versions.Project.PropertyGroup.VersionPrefix -cne '0.1.0' -or - $versions.Project.PropertyGroup.PreReleaseVersionLabel -cne 'beta') { - throw 'Retirement changed the shared base-version properties.' - } - [xml] $build = Get-Content -LiteralPath (Join-Path $fixture 'eng\Build.props') -Raw - $projects = @($build.Project.ItemGroup.ProjectToBuild | ForEach-Object { $_.GetAttribute('Include') }) - if ($projects -notcontains '$(RepoRoot)eng\Infrastructure.proj' -or - $projects -notcontains '$(RepoRoot)eng\FutureTool.proj') { - throw 'Retirement removed infrastructure or another tool from the root graph.' - } - if (-not ([IO.File]::ReadAllText((Join-Path $fixture 'eng\Signing.props'))).Contains('future-tool\Signing.props')) { - throw 'Retirement removed another signing module.' - } - - $publicPath = Join-Path $fixture 'eng\pipelines\pr.yml' - $public = [IO.File]::ReadAllText($publicPath).Replace("`r`n", "`n") - $public = $public.Replace('- template: /eng/pipelines/dotnet-package-skills/stage.yml', @' -- stage: ArcadeInfrastructure - jobs: - - template: /eng/common/templates/jobs/jobs.yml - parameters: - runAsPublic: true - enableMicrobuild: false - enableTelemetry: false - enablePublishBuildAssets: false - jobs: - - job: Infrastructure - steps: - - script: eng\common\build.cmd -restore -build -configuration Release -ci -'@) - [IO.File]::WriteAllText($publicPath, $public) - $officialPath = Join-Path $fixture 'eng\pipelines\official.yml' - $official = [IO.File]::ReadAllText($officialPath).Replace("`r`n", "`n") - $removedParameters = @' -parameters: -- name: DotnetPackageSkillsReleaseBuild - type: boolean - default: false - -'@ - $official = $official.Replace($removedParameters.Replace("`r`n", "`n"), '') - $removedStage = @' - - template: /eng/pipelines/dotnet-package-skills/stage.yml@self - parameters: - isOfficialBuild: true - releaseBuild: ${{ parameters.DotnetPackageSkillsReleaseBuild }} -'@ - $infrastructureStage = @' - - stage: ArcadeInfrastructure - jobs: - - template: /eng/common/templates-official/jobs/jobs.yml@self - parameters: - enableMicrobuild: false - enableTelemetry: false - enablePublishBuildAssets: false - jobs: - - job: Infrastructure - steps: - - script: eng\common\build.cmd -restore -build -configuration Release -ci -'@ - $official = $official.Replace($removedStage.Replace("`r`n", "`n"), $infrastructureStage.Replace("`r`n", "`n")) - [IO.File]::WriteAllText($officialPath, $official) - foreach ($path in @($publicPath, $officialPath)) { - if ([IO.File]::ReadAllText($path) -match 'dotnet-package-skills|DotnetPackageSkillsReleaseBuild') { - throw "Retirement left a tool reference in $path." - } - } - if ($official -notmatch 'v1/1ES\.Official\.PipelineTemplate\.yml@1esPipelines') { - throw 'Retirement removed 1ES governance.' - } - - Remove-Item -LiteralPath (Join-Path $fixture 'dotnet-package-skills') -Recurse -Force - Remove-Item -LiteralPath (Join-Path $fixture 'eng\pipelines\dotnet-package-skills') -Recurse -Force - foreach ($file in @('global.json', 'NuGet.Config', 'Directory.Build.props', 'Directory.Build.targets', 'LICENSE')) { - if ((Get-FileHash -LiteralPath (Join-Path $source $file)).Hash -cne - (Get-FileHash -LiteralPath (Join-Path $fixture $file)).Hash) { - throw "Retirement changed the shared foundation file $file." - } - } - foreach ($file in Get-ChildItem -LiteralPath (Join-Path $source 'eng\common') -Recurse -File) { - $relative = [IO.Path]::GetRelativePath($source, $file.FullName) - if ((Get-FileHash -LiteralPath $file.FullName).Hash -cne - (Get-FileHash -LiteralPath (Join-Path $fixture $relative)).Hash) { - throw "Retirement changed vendored Arcade $relative." - } - } - Set-Location -LiteralPath $fixture - & (Join-Path $fixture 'eng\common\build.cmd') -restore -build -configuration Release -ci /p:NETCORE_ENGINEERING_TELEMETRY=false - if ($LASTEXITCODE -ne 0) { throw 'The retired fixture could not restore and build the shared Arcade graph.' } - Write-Host 'Isolated retirement validation succeeded; shared infrastructure and future tool references are intact.' -} -finally { - Set-Location -LiteralPath $originalLocation - Remove-Item -LiteralPath $fixture -Recurse -Force -} diff --git a/eng/pipelines/dotnet-package-skills/Validate-OfficialSource.ps1 b/eng/pipelines/dotnet-package-skills/Validate-OfficialSource.ps1 deleted file mode 100644 index e7ff689..0000000 --- a/eng/pipelines/dotnet-package-skills/Validate-OfficialSource.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -[CmdletBinding()] -param([switch] $ReleaseBuild) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -if ($env:BUILD_REPOSITORY_PROVIDER -cne 'TfsGit' -or - $env:BUILD_REPOSITORY_NAME -cne 'NuGet-Client.Tools' -or - $env:SYSTEM_TEAMPROJECT -cne 'internal' -or - $env:SYSTEM_COLLECTIONURI -cne 'https://dev.azure.com/dnceng/' -or - $env:BUILD_SOURCEBRANCH -cne 'refs/heads/main' -or - [string]::IsNullOrWhiteSpace($env:BUILD_REASON) -or $env:BUILD_REASON -eq 'PullRequest') { - throw 'Official signing requires main from the trusted dnceng/internal/NuGet-Client.Tools repository, with non-PR source metadata.' -} -if ($ReleaseBuild -and $env:BUILD_REASON -cne 'Manual') { - throw 'DotnetPackageSkillsReleaseBuild requires a manual official build on trusted main.' -} -$team = $env:DOTNET_PACKAGE_SKILLS_SIGNING_TEAM -if ([string]::IsNullOrWhiteSpace($team) -or $team -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$') { - throw 'Configure the owner-approved DotnetPackageSkillsSigningTeamName on the official definition; a missing or unresolved signing team is not allowed.' -} -Write-Host 'Trusted source and configured signing team validated. Signing resource authorization remains an owner prerequisite.' diff --git a/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 b/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 deleted file mode 100644 index 15c6e63..0000000 --- a/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 +++ /dev/null @@ -1,132 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [string] $PackagePath, - - [Parameter(Mandatory)] - [string] $ExpectedVersion, - - [Parameter(Mandatory)] - [string] $BuildOutputPath, - - [Parameter(Mandatory)] - [string] $DotnetPath, - - [Parameter(Mandatory)] - [string] $StrongNameToolPath, - - [string] $ExpectedCommit, - - [switch] $RequireSigned -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -Import-Module (Join-Path $PSScriptRoot 'Package-Validation.psm1') -Force -Test-DotnetPackageSkillsPackage @PSBoundParameters -$package = (Resolve-Path -LiteralPath $PackagePath).Path -$DotnetPath = (Resolve-Path -LiteralPath $DotnetPath).Path -$runtimes = Invoke-CheckedCommand $DotnetPath @('--list-runtimes') -foreach ($major in @('8', '10')) { - if ($runtimes -notmatch "(?m)^Microsoft\.NETCore\.App $major\.") { - throw "The Arcade dotnet installation is missing the .NET $major runtime." - } -} - -$temporary = [IO.Directory]::CreateTempSubdirectory('dotnet-package-skills-verify-').FullName -$originalEnvironment = @{} -foreach ($name in @( - 'NUGET_PACKAGES', 'DOTNET_CLI_HOME', 'DOTNET_ROLL_FORWARD', - 'DOTNET_GENERATE_ASPNET_CERTIFICATE', 'DOTNET_ADD_GLOBAL_TOOLS_TO_PATH', - 'DOTNET_CLI_TELEMETRY_OPTOUT', 'DOTNET_NOLOGO', 'DOTNET_ROOT', 'DOTNET_ROOT_X64', - 'DOTNET_MULTILEVEL_LOOKUP' -)) { - $originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) -} - -try { - $feed = [IO.Directory]::CreateDirectory((Join-Path $temporary 'feed')).FullName - Copy-Item -LiteralPath $package -Destination $feed - $config = Join-Path $temporary 'NuGet.config' - $escapedFeed = [Security.SecurityElement]::Escape($feed) - [IO.File]::WriteAllText($config, @" - - - - - -"@) - $env:NUGET_PACKAGES = Join-Path $temporary 'nuget-cache' - $env:DOTNET_CLI_HOME = Join-Path $temporary 'cli-home' - $env:DOTNET_ROLL_FORWARD = 'LatestPatch' - $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = 'false' - $env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = 'false' - $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' - $env:DOTNET_NOLOGO = '1' - $env:DOTNET_ROOT = Split-Path -Parent $DotnetPath - $env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT - $env:DOTNET_MULTILEVEL_LOOKUP = '0' - - $fixtureCache = Join-Path $temporary 'fixture-cache' - $skill = Join-Path $fixtureCache 'contoso.widgets\2.3.0\skills\contoso.widgets-widget-usage' - [IO.Directory]::CreateDirectory($skill) | Out-Null - [IO.File]::WriteAllText((Join-Path $skill 'SKILL.md'), "---`nname: contoso.widgets-widget-usage`ndescription: Local package verification fixture.`n---`n") - - foreach ($framework in @('net8.0', 'net10.0')) { - $toolPath = Join-Path $temporary "tools-$framework" - Write-Host (Invoke-CheckedCommand $DotnetPath @( - 'tool', 'install', 'dotnet-package-skills', '--tool-path', $toolPath, - '--version', $ExpectedVersion, '--framework', $framework, - '--configfile', $config, '--no-http-cache', '--verbosity', 'quiet' - )) - - $tool = Join-Path $toolPath 'dotnet-package-skills.exe' - $version = (Invoke-CheckedCommand $tool @('--version')).Trim() - if (($version -split '\+', 2)[0] -cne $ExpectedVersion) { - throw "Installed $framework tool version '$version' does not match '$ExpectedVersion'." - } - $help = Invoke-CheckedCommand $tool @('--help') - if ($help -notmatch 'install' -or $help -notmatch 'uninstall') { - throw "Installed $framework tool does not expose its expected commands." - } - - $destination = Join-Path $temporary "skills-$framework" - $handwritten = Join-Path $destination 'handwritten' - [IO.Directory]::CreateDirectory($handwritten) | Out-Null - [IO.File]::WriteAllText((Join-Path $handwritten 'SKILL.md'), 'Preserve this skill.') - $packageArguments = @('--package', 'Contoso.Widgets@2.3.0', '--global-packages', $fixtureCache, '--destination', $destination) - - $listing = Invoke-CheckedCommand $tool (@('list') + $packageArguments) - if ($listing -notmatch 'contoso.widgets-widget-usage') { - throw "Installed $framework tool did not discover the local fixture." - } - Write-Host (Invoke-CheckedCommand $tool (@('install') + $packageArguments)) - $installedSkill = Join-Path $destination 'contoso.widgets-widget-usage\SKILL.md' - if (-not (Test-Path -LiteralPath $installedSkill)) { - throw "Installed $framework tool did not copy the fixture skill." - } - $manifestPath = Join-Path $destination '.dotnet-package-skills.json' - $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json - if ($manifest.packages.'contoso.widgets'.version -cne '2.3.0') { - throw "Installed $framework tool did not record the fixture package version." - } - Write-Host (Invoke-CheckedCommand $tool @('uninstall', '--package', 'Contoso.Widgets', '--destination', $destination)) - if (Test-Path -LiteralPath $installedSkill) { - throw "Installed $framework tool did not remove the fixture skill." - } - if ([IO.File]::ReadAllText((Join-Path $handwritten 'SKILL.md')) -cne 'Preserve this skill.') { - throw "Installed $framework tool changed a handwritten skill." - } - } -} -finally { - foreach ($name in $originalEnvironment.Keys) { - $value = $originalEnvironment[$name] - if ($null -eq $value) { $value = [NullString]::Value } - [Environment]::SetEnvironmentVariable($name, $value) - } - Remove-Item -LiteralPath $temporary -Recurse -Force -} - -Write-Host "Verified dotnet-package-skills $ExpectedVersion on .NET 8 and .NET 10." diff --git a/eng/pipelines/dotnet-package-skills/jobs-build.yml b/eng/pipelines/dotnet-package-skills/jobs-build.yml deleted file mode 100644 index e5a3a5c..0000000 --- a/eng/pipelines/dotnet-package-skills/jobs-build.yml +++ /dev/null @@ -1,92 +0,0 @@ -parameters: -- name: isOfficialBuild - type: boolean - default: false -- name: releaseBuild - type: boolean - default: false -- name: jobsTemplate - type: string - default: /eng/common/templates/jobs/jobs.yml - -jobs: -- template: ${{ parameters.jobsTemplate }} - parameters: - runAsPublic: ${{ not(parameters.isOfficialBuild) }} - enableMicrobuild: ${{ parameters.isOfficialBuild }} - microbuildUseESRP: true - enableMicrobuildForMacAndLinux: false - enableTelemetry: false - enablePublishBuildAssets: false - enablePublishing: false - enablePublishBuildArtifacts: false - enablePublishTestResults: false - artifacts: - publish: - logs: true - jobs: - - job: DotnetPackageSkillsWindows - displayName: Build, test, pack, and verify dotnet-package-skills - timeoutInMinutes: 60 - variables: - - name: _BuildConfig - value: Release - - name: DotnetPackageSkillsPackageVerified - value: 'false' - - name: DotnetPackageSkillsPipelineDirectory - value: $(Build.SourcesDirectory)\eng\pipelines\dotnet-package-skills - - name: DotnetPackageSkillsPackageArtifactPath - value: $(Build.ArtifactStagingDirectory)\dotnet-package-skills\packages - - name: DOTNET_INSTALL_DIR - value: $(Build.SourcesDirectory)\.dotnet - - name: DOTNET_ROOT - value: $(Build.SourcesDirectory)\.dotnet - - name: DOTNET_ROOT_X64 - value: $(Build.SourcesDirectory)\.dotnet - - name: DOTNET_NOLOGO - value: '1' - - name: DOTNET_CLI_TELEMETRY_OPTOUT - value: '1' - - name: DOTNET_GENERATE_ASPNET_CERTIFICATE - value: 'false' - - name: DOTNET_ADD_GLOBAL_TOOLS_TO_PATH - value: 'false' - - ${{ if eq(parameters.isOfficialBuild, true) }}: - - name: _SignType - value: real - - name: _TeamName - value: $(DotnetPackageSkillsSigningTeamName) - - name: DOTNET_PACKAGE_SKILLS_SIGNING_TEAM - value: $(DotnetPackageSkillsSigningTeamName) - ${{ if eq(parameters.isOfficialBuild, true) }}: - templateContext: - outputs: - - output: pipelineArtifact - targetPath: $(DotnetPackageSkillsPackageArtifactPath) - artifactName: dotnet-package-skills-packages - condition: and(succeeded(), eq(variables['DotnetPackageSkillsPackageVerified'], 'true')) - - output: pipelineArtifact - targetPath: $(Build.SourcesDirectory)\artifacts\log\Release - artifactName: dotnet-package-skills-logs - condition: succeededOrFailed() - isProduction: false - - output: pipelineArtifact - targetPath: $(Build.SourcesDirectory)\artifacts\TestResults\Release - artifactName: dotnet-package-skills-testresults - condition: succeededOrFailed() - isProduction: false - preSteps: - - checkout: self - clean: true - fetchDepth: 0 - fetchTags: false - - ${{ if eq(parameters.isOfficialBuild, true) }}: - - pwsh: | - & "$(DotnetPackageSkillsPipelineDirectory)\Validate-OfficialSource.ps1" ` - -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) - displayName: Validate trusted source and approved signing team before MicroBuild - steps: - - template: /eng/pipelines/dotnet-package-skills/steps-build.yml - parameters: - isOfficialBuild: ${{ parameters.isOfficialBuild }} - releaseBuild: ${{ parameters.releaseBuild }} diff --git a/eng/pipelines/dotnet-package-skills/stage.yml b/eng/pipelines/dotnet-package-skills/stage.yml index 39f9676..3dd4bf2 100644 --- a/eng/pipelines/dotnet-package-skills/stage.yml +++ b/eng/pipelines/dotnet-package-skills/stage.yml @@ -5,35 +5,79 @@ parameters: - name: releaseBuild type: boolean default: false +- name: jobsTemplate + type: string + default: /eng/common/templates/jobs/jobs.yml stages: - stage: DotnetPackageSkills - displayName: dotnet-package-skills - dependsOn: [] jobs: - - ${{ if eq(parameters.isOfficialBuild, true) }}: - - ${{ if and(eq(variables['Build.Repository.Provider'], 'TfsGit'), eq(variables['Build.Repository.Name'], 'NuGet-Client.Tools'), eq(variables['System.TeamProject'], 'internal'), eq(variables['System.CollectionUri'], 'https://dev.azure.com/dnceng/'), eq(variables['Build.SourceBranch'], 'refs/heads/main'), ne(variables['Build.Reason'], ''), ne(variables['Build.Reason'], 'PullRequest')) }}: - - template: jobs-build.yml - parameters: - isOfficialBuild: true - releaseBuild: ${{ parameters.releaseBuild }} - jobsTemplate: /eng/common/templates-official/jobs/jobs.yml@self - - ${{ else }}: - - template: /eng/common/templates-official/jobs/jobs.yml@self - parameters: - runAsPublic: true - enableMicrobuild: false - enableTelemetry: false - enablePublishBuildAssets: false - enablePublishing: false - jobs: - - job: RejectUntrustedOfficialSource - displayName: Reject untrusted official source without signing resources - steps: - - pwsh: | - throw 'Official signing requires main from the trusted dnceng/internal/NuGet-Client.Tools repository with non-PR metadata.' - displayName: Reject untrusted source - - ${{ else }}: - - template: jobs-build.yml - parameters: - releaseBuild: ${{ parameters.releaseBuild }} + - template: ${{ parameters.jobsTemplate }} + parameters: + runAsPublic: ${{ not(parameters.isOfficialBuild) }} + enableMicrobuild: ${{ parameters.isOfficialBuild }} + enableTelemetry: false + enablePublishBuildAssets: false + testResultsFormat: vstest + artifacts: + publish: + logs: true + jobs: + - job: Windows + variables: + - name: _BuildConfig + value: Release + - name: DOTNET_INSTALL_DIR + value: $(Build.SourcesDirectory)\.dotnet + - name: _FinalVersionKind + ${{ if and(parameters.isOfficialBuild, parameters.releaseBuild) }}: + value: release + ${{ else }}: + value: '' + - ${{ if eq(parameters.isOfficialBuild, true) }}: + - name: _SignType + value: real + - name: _TeamName + value: $(DotnetPackageSkillsSigningTeamName) + - name: _OfficialBuildArgs + value: -sign /p:DotNetSignType=real /p:OfficialBuildId=$(Build.BuildNumber) /p:TeamName=$(_TeamName) + - ${{ else }}: + - name: _OfficialBuildArgs + value: '' + ${{ if eq(parameters.isOfficialBuild, true) }}: + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)\packages + artifactName: dotnet-package-skills-packages + condition: succeeded() + steps: + - task: UseDotNet@2 + displayName: Install the root-pinned SDK + inputs: + packageType: sdk + useGlobalJson: true + workingDirectory: $(Build.SourcesDirectory) + installationPath: $(DOTNET_INSTALL_DIR) + - task: UseDotNet@2 + displayName: Install the .NET 8 test runtime + inputs: + packageType: runtime + version: 8.0.x + installationPath: $(DOTNET_INSTALL_DIR) + - script: >- + eng\common\build.cmd -restore -build -test -pack -configuration Release -ci + $(_OfficialBuildArgs) /p:DotNetFinalVersionKind=$(_FinalVersionKind) + /p:NETCORE_ENGINEERING_TELEMETRY=false + displayName: Build, test, and package with Arcade + - task: CopyFiles@2 + displayName: Stage NuGet packages + inputs: + SourceFolder: $(Build.SourcesDirectory)\artifacts\packages\Release\Shipping + Contents: 'dotnet-package-skills.*.nupkg' + TargetFolder: $(Build.ArtifactStagingDirectory)\packages + - ${{ if eq(parameters.isOfficialBuild, false) }}: + - task: PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)\packages + artifact: dotnet-package-skills-packages diff --git a/eng/pipelines/dotnet-package-skills/steps-build.yml b/eng/pipelines/dotnet-package-skills/steps-build.yml deleted file mode 100644 index b31e4ac..0000000 --- a/eng/pipelines/dotnet-package-skills/steps-build.yml +++ /dev/null @@ -1,103 +0,0 @@ -parameters: -- name: isOfficialBuild - type: boolean - default: false -- name: releaseBuild - type: boolean - default: false - -steps: -- pwsh: | - foreach ($directory in @( - "$(Build.SourcesDirectory)\artifacts\log\Release", - "$(Build.SourcesDirectory)\artifacts\TestResults\Release", - "$(DotnetPackageSkillsPackageArtifactPath)" - )) { - [IO.Directory]::CreateDirectory($directory) | Out-Null - } - displayName: Prepare native diagnostic and package artifact directories - -- task: UseDotNet@2 - displayName: Install root-pinned .NET SDK into the Arcade installation - inputs: - packageType: sdk - useGlobalJson: true - workingDirectory: $(Build.SourcesDirectory) - installationPath: $(DOTNET_INSTALL_DIR) - -- task: UseDotNet@2 - displayName: Install .NET 8 runtime into the same Arcade installation - inputs: - packageType: runtime - version: 8.0.x - installationPath: $(DOTNET_INSTALL_DIR) - -- pwsh: | - & "$(DotnetPackageSkillsPipelineDirectory)\Invoke-Build.ps1" -Action RestoreBuild -CI ` - -Official:([bool]::Parse('${{ parameters.isOfficialBuild }}')) ` - -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) - displayName: Restore and build the root Arcade graph, including infrastructure validation - workingDirectory: $(Build.SourcesDirectory) - -- pwsh: | - & "$(DotnetPackageSkillsPipelineDirectory)\Invoke-Build.ps1" -Action Test -CI ` - -Official:([bool]::Parse('${{ parameters.isOfficialBuild }}')) ` - -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) - displayName: Run C# tests on both frameworks and reject missing or failed results - workingDirectory: $(Build.SourcesDirectory) - -- task: PublishTestResults@2 - displayName: Publish both C# framework results with fail-closed checks - condition: succeededOrFailed() - inputs: - testResultsFormat: VSTest - testResultsFiles: $(Build.SourcesDirectory)\artifacts\TestResults\Release\DotnetPackageSkills.Tests_*.trx - testRunTitle: dotnet-package-skills - failTaskOnFailedTests: true - failTaskOnMissingResultsFile: true - failTaskOnFailureToPublishResults: true - -- pwsh: | - & "$(DotnetPackageSkillsPipelineDirectory)\Invoke-Build.ps1" -Action Pack -CI ` - -Official:([bool]::Parse('${{ parameters.isOfficialBuild }}')) ` - -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) - displayName: Pack the native shipping package without rebuilding - workingDirectory: $(Build.SourcesDirectory) - -- pwsh: | - & "$(DotnetPackageSkillsPipelineDirectory)\Invoke-Build.ps1" -Action Sign -CI ` - -Official:([bool]::Parse('${{ parameters.isOfficialBuild }}')) ` - -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) - ${{ if eq(parameters.isOfficialBuild, true) }}: - displayName: Recursively real-sign payloads, repack, and sign the package through Arcade - ${{ else }}: - displayName: Validate the Arcade signing plan without signing or production access - workingDirectory: $(Build.SourcesDirectory) - -- pwsh: | - & "$(DotnetPackageSkillsPipelineDirectory)\Invoke-Build.ps1" -Action Verify -CI ` - -Official:([bool]::Parse('${{ parameters.isOfficialBuild }}')) ` - -ReleaseBuild:([bool]::Parse('${{ parameters.releaseBuild }}')) ` - -PackageArtifactPath "$(DotnetPackageSkillsPackageArtifactPath)" - displayName: Verify and install only the exact native package on both runtimes - workingDirectory: $(Build.SourcesDirectory) - -- ${{ if eq(parameters.isOfficialBuild, false) }}: - - task: PublishPipelineArtifact@1 - displayName: Publish the verified unsigned tool package - condition: and(succeeded(), eq(variables['DotnetPackageSkillsPackageVerified'], 'true')) - inputs: - targetPath: $(DotnetPackageSkillsPackageArtifactPath) - artifact: dotnet-package-skills-packages - - task: PublishPipelineArtifact@1 - displayName: Retain native diagnostic logs - condition: succeededOrFailed() - inputs: - targetPath: $(Build.SourcesDirectory)\artifacts\log\Release - artifact: dotnet-package-skills-logs - - task: PublishPipelineArtifact@1 - displayName: Retain both framework result files - condition: succeededOrFailed() - inputs: - targetPath: $(Build.SourcesDirectory)\artifacts\TestResults\Release - artifact: dotnet-package-skills-testresults diff --git a/eng/pipelines/official.yml b/eng/pipelines/official.yml index e508a8d..0d863a7 100644 --- a/eng/pipelines/official.yml +++ b/eng/pipelines/official.yml @@ -26,7 +26,16 @@ extends: demands: ImageOverride -equals windows.vs2026.amd64 os: windows stages: - - template: /eng/pipelines/dotnet-package-skills/stage.yml@self - parameters: - isOfficialBuild: true - releaseBuild: ${{ parameters.DotnetPackageSkillsReleaseBuild }} + - ${{ if and(eq(variables['Build.Repository.Provider'], 'TfsGit'), eq(variables['Build.Repository.Name'], 'NuGet-Client.Tools'), eq(variables['System.TeamProject'], 'internal'), eq(variables['System.CollectionUri'], 'https://dev.azure.com/dnceng/'), eq(variables['Build.SourceBranch'], 'refs/heads/main'), ne(variables['Build.Reason'], ''), ne(variables['Build.Reason'], 'PullRequest'), or(eq(parameters.DotnetPackageSkillsReleaseBuild, false), eq(variables['Build.Reason'], 'Manual'))) }}: + - template: /eng/pipelines/dotnet-package-skills/stage.yml@self + parameters: + jobsTemplate: /eng/common/templates-official/jobs/jobs.yml@self + isOfficialBuild: true + releaseBuild: ${{ parameters.DotnetPackageSkillsReleaseBuild }} + - ${{ else }}: + - stage: DotnetPackageSkills + jobs: + - job: RejectUntrustedSource + steps: + - script: exit /b 1 + displayName: Official builds require trusted internal main; stable releases require a manual run From 020fb85480b6b2d345464a247ca80b2b05b66189 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Tue, 6 Oct 2026 11:59:47 -0700 Subject: [PATCH 04/10] Replace SharpYaml with YamlDotNet for skill descriptions Use YamlDotNet 18.1.0 event parsing while preserving bounded frontmatter reads, nesting limits, metadata guards, and blank descriptions. Add focused application regressions for blank trailing blocks and update the existing third-party signing mapping without changing the minimal pipelines. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/DotnetPackageSkills.csproj | 2 +- .../src/Skills/SkillDescriptionReader.cs | 49 ++++++++++++------- .../tests/SkillDescriptionReaderTests.cs | 18 +++++++ eng/pipelines/dotnet-package-skills/README.md | 2 +- .../dotnet-package-skills/Signing.props | 2 +- 5 files changed, 53 insertions(+), 20 deletions(-) diff --git a/dotnet-package-skills/src/DotnetPackageSkills.csproj b/dotnet-package-skills/src/DotnetPackageSkills.csproj index a323a0e..ac20ae2 100644 --- a/dotnet-package-skills/src/DotnetPackageSkills.csproj +++ b/dotnet-package-skills/src/DotnetPackageSkills.csproj @@ -31,7 +31,7 @@ - + diff --git a/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs b/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs index e093b72..203feef 100644 --- a/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs +++ b/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs @@ -1,6 +1,6 @@ using System.Text; -using SharpYaml; -using SharpYaml.Events; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; namespace DotnetPackageSkills.Skills; @@ -137,14 +137,29 @@ private static int ReadCharacter(StreamReader reader, ref int charactersRead) private static SkillDescriptionResult Parse(string frontmatter) { - var parser = new EventReader(Parser.CreateParser(new StringReader(frontmatter))); - parser.Expect(); - if (parser.Allow() is not null) + try + { + return ParseDocument(frontmatter); + } + catch (SemanticErrorException exception) when ( + exception.Start.Index == frontmatter.Length && exception.End.Index == frontmatter.Length) + { + // YamlDotNet rejects indentation in an all-blank trailing block. Reparse the + // whole document without trailing whitespace, keeping all metadata checks. + return ParseDocument(frontmatter.TrimEnd(' ', '\t', '\n') + '\n'); + } + } + + private static SkillDescriptionResult ParseDocument(string frontmatter) + { + var parser = new Parser(new StringReader(frontmatter)); + parser.Consume(); + if (parser.TryConsume(out _)) { return new(null, null); } - parser.Expect(); + parser.Consume(); var root = ReadNodeStart(parser); if (root is not MappingStart) { @@ -153,7 +168,7 @@ private static SkillDescriptionResult Parse(string frontmatter) string? description = null; var foundDescription = false; - while (parser.Allow() is null) + while (!parser.TryConsume(out _)) { var key = ReadNode(parser, 2); var value = ReadNode(parser, 2); @@ -176,12 +191,12 @@ private static SkillDescriptionResult Parse(string frontmatter) description = string.IsNullOrWhiteSpace(value.Value) ? null : value.Value; } - parser.Expect(); - parser.Expect(); + parser.Consume(); + parser.Consume(); return new(description, null); } - private static Scalar? ReadNode(EventReader parser, int depth) + private static Scalar? ReadNode(IParser parser, int depth) { var node = ReadNodeStart(parser); if (node is Scalar scalar) @@ -196,7 +211,7 @@ private static SkillDescriptionResult Parse(string frontmatter) if (node is MappingStart) { - while (parser.Allow() is null) + while (!parser.TryConsume(out _)) { ReadNode(parser, depth + 1); ReadNode(parser, depth + 1); @@ -204,7 +219,7 @@ private static SkillDescriptionResult Parse(string frontmatter) } else { - while (parser.Allow() is null) + while (!parser.TryConsume(out _)) { ReadNode(parser, depth + 1); } @@ -213,20 +228,20 @@ private static SkillDescriptionResult Parse(string frontmatter) return null; } - private static NodeEvent ReadNodeStart(EventReader parser) + private static NodeEvent ReadNodeStart(IParser parser) { - if (parser.Accept()) + if (parser.Accept(out _)) { throw new FrontmatterException("uses YAML anchors or aliases; replace them with literal values."); } - var node = parser.Expect(); - if (!string.IsNullOrEmpty(node.Anchor)) + var node = parser.Consume(); + if (!node.Anchor.IsEmpty) { throw new FrontmatterException("uses YAML anchors or aliases; replace them with literal values."); } - if (!string.IsNullOrEmpty(node.Tag)) + if (!node.Tag.IsEmpty) { throw new FrontmatterException("uses explicit YAML tags; remove the tags from its frontmatter."); } diff --git a/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs b/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs index c764674..d4e4b8b 100644 --- a/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs +++ b/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs @@ -58,6 +58,11 @@ public void A_top_level_scalar_preserves_the_YAML_description_value(string yaml, [InlineData("description: \" \"")] [InlineData("description: \"\\n\\t\\r\"")] [InlineData("description: |\n \n ")] + [InlineData("description: |-\n \n ")] + [InlineData("description: |+\n \n ")] + [InlineData("description: >\n \n ")] + [InlineData("description: >-\n \n ")] + [InlineData("description: >+\n \n ")] public void Missing_or_blank_descriptions_have_no_warning(string yaml) { using var temp = new TempDirectory(); @@ -69,6 +74,18 @@ public void Missing_or_blank_descriptions_have_no_warning(string yaml) Assert.Null(result.Warning); } + [Fact] + public void A_trailing_blank_block_does_not_change_an_earlier_description() + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, "description: |+\n First\n \n Second\n\nother: |\n \n "); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("First\n \nSecond\n\n", result.Description); + Assert.Null(result.Warning); + } + [Fact] public void Nested_descriptions_do_not_replace_the_top_level_description() { @@ -174,6 +191,7 @@ public void A_closing_delimiter_does_not_require_a_final_newline(string delimite [InlineData("description: Valid first\nlater: {unfinished")] [InlineData("name: No description\nlater: [unfinished")] [InlineData("description: Valid first\nlater:\n\tinvalid: indentation")] + [InlineData("description: |\n \n Content")] public void Malformed_YAML_warns_instead_of_returning_partial_metadata(string yaml) { using var temp = new TempDirectory(); diff --git a/eng/pipelines/dotnet-package-skills/README.md b/eng/pipelines/dotnet-package-skills/README.md index 39d7377..276a760 100644 --- a/eng/pipelines/dotnet-package-skills/README.md +++ b/eng/pipelines/dotnet-package-skills/README.md @@ -52,7 +52,7 @@ the reviewed main commit before a real-signing run. Root `eng\Signing.props` imports the concise module `Signing.props`. It lists the tool package and both loose owned DLLs. Arcade uses the .NET certificate for first-party DLLs and the -standard NuGet profile for the package. SharpYaml uses `3PartySHA2`. Already signed Microsoft +standard NuGet profile for the package. YamlDotNet uses `3PartySHA2`. Already signed Microsoft dependencies keep their original signatures. This follows how [dotnet/sign builds its own releases](https://github.com/dotnet/sign/blob/f3cc758ffd4f5edccb567820606b17eea49745c0/.vsts-ci.yml), not the public sign CLI. Real-signing acceptance requires a successful authorized official run; diff --git a/eng/pipelines/dotnet-package-skills/Signing.props b/eng/pipelines/dotnet-package-skills/Signing.props index 508708a..f095b90 100644 --- a/eng/pipelines/dotnet-package-skills/Signing.props +++ b/eng/pipelines/dotnet-package-skills/Signing.props @@ -9,6 +9,6 @@ - + From 420be1ae75dcd0c7fe8fa839d82497a494cc0e5f Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Tue, 6 Oct 2026 12:17:08 -0700 Subject: [PATCH 05/10] Restore the repository standard MIT license Revert only the PR-specific license heading and reserved-rights formatting at the user request. Keep the original main license text and do not change or disable Arcade validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- LICENSE | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index 2bdf256..b26b9d4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,7 @@ -The MIT License (MIT) +MIT License Copyright (c) 2026 NuGet -All rights reserved. - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights From 1cbd05712544dcea789abfc77194f7cd9224beef Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Tue, 6 Oct 2026 12:25:33 -0700 Subject: [PATCH 06/10] Restore Arcade-compatible MIT license formatting Restore only the MIT heading and reserved-rights line with user approval so the enabled Arcade license check passes. Preserve the NuGet copyright and full MIT grant; no pipeline or validation settings change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- LICENSE | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index b26b9d4..2bdf256 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,9 @@ -MIT License +The MIT License (MIT) Copyright (c) 2026 NuGet +All rights reserved. + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights From 3bf02ff259e268dfa4e7baf0af0bb7305d811549 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Tue, 6 Oct 2026 13:49:37 -0700 Subject: [PATCH 07/10] Clean up CONTRIBUTING.md by removing old sections Removed outdated origin and rules sections from CONTRIBUTING.md to streamline the document. --- dotnet-package-skills/CONTRIBUTING.md | 371 +------------------------- 1 file changed, 3 insertions(+), 368 deletions(-) diff --git a/dotnet-package-skills/CONTRIBUTING.md b/dotnet-package-skills/CONTRIBUTING.md index 0cd7ebe..8ebd8ed 100644 --- a/dotnet-package-skills/CONTRIBUTING.md +++ b/dotnet-package-skills/CONTRIBUTING.md @@ -6,10 +6,9 @@ too. ## Set up your computer Install the .NET SDK that the repository root's `global.json` selects. Install PowerShell 7. -Install the .NET 8 runtime into the same .NET installation that Arcade uses. The SDK supplies -the .NET 10 runtime. The tool and its C# test suite target both net8.0 and net10.0. CI runs on -Windows only. Use the root SDK, Arcade imports, and public feeds. Do not add a tool-local -`global.json` or `NuGet.config`. +Install the .NET 8 runtime into the same .NET installation that Arcade uses. The SDK supplies the .NET 10 runtime. +The tool and its C# test suite target both net8.0 and net10.0. CI runs on Windows only. +Use the root SDK, Arcade imports, and public feeds. Do not add a tool-local `global.json` or `NuGet.config`. ```powershell git clone https://github.com/NuGet/Client.Tools.git @@ -27,21 +26,6 @@ eng\common\dotnet.cmd artifacts\bin\DotnetPackageSkills\Release\net10.0\dotnet-p The build creates an unsigned tool package under `artifacts\packages\Release\Shipping`. It does not replace a globally installed tool. Only the official pipeline signs the package. -## Origin - -This folder came from -[`kartheekp-ms/dotnet-package-skills` at `59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3`](https://github.com/kartheekp-ms/dotnet-package-skills/tree/59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3). -We copied the source files at that commit. We did not copy or change the original repository's -history. The source repository is unchanged. - -The source README and package metadata name the MIT license. Client.Tools keeps its own root MIT -license file. We left out the Python and Node.js terminal regression tests on purpose. The C# -picker tests and the interactive tool behavior stay the same. Build configuration and pipeline -integration live in this tool's pipeline folder, with small root build, signing, and -stage references. Remove these references with the tool, but keep the shared Arcade foundation. -This lets a maintainer retire the tool on its own, for example when its function moves into the -.NET SDK. - ## Layout ``` @@ -58,352 +42,3 @@ src/ tests/ xunit tests. Application tests use in-process fakes. ``` - -## Rules that must stay true - -Read this list before you change this tool. Each rule protects something that is not obvious -from the code alone. Do not change a rule without a discussion first. - -**Copy files from the global packages folder. Never move them.** NuGet validates this folder -during restore. It is a content-addressable cache, and every project on the machine shares it. -If you move a file out of the cache, restore may treat the cached package as damaged. Moving a -file also removes the skill from every other repository that uses that package. - -**Use the manifest to decide what to remove. Never scan the destination folder to decide.** -`.dotnet-package-skills.json` records, for each package ID, the one installed version and the -names of the skill folders it owns. `install` acts only on those names when a package changes -version. `uninstall` acts only on those names too. Users keep their own hand-written skills in -the same folder. Deleting one of those skills by mistake is a serious failure. - -**An unreadable manifest stops every operation that needs ownership information.** Do not treat -a damaged or unreadable manifest as an empty manifest. An empty manifest means the existing -folders belong to the user. A damaged manifest means the tool does not know who owns the -folders. `install` and `uninstall` must fail before they change anything. They must keep the -file in place so the user can repair it or restore it. `list` can still run, because `list` does -not read ownership information and does not write anything. - -An existing manifest must have these parts. It must have a format `version` property, and the -value must be a whole number that this version of the tool supports. It must have a `packages` -object. Each package ID in that object must be valid under NuGet's own rule. `PackageCoordinate.IsValidId` -checks this rule, and the rule allows letters outside ASCII. Each package needs a version that is -not empty. JSON property names must be unique when you ignore letter case. Destination names -claimed by different packages must be unique when you ignore letter case. When the manifest has -a newer format version than the tool supports, the tool asks the user to update the tool. A -pre-release manifest has an `installed` array instead of a `packages` object. The tool refuses -this old format. It does not convert the file. `SetSkills` refuses any ID that the reader would -refuse. This way, the tool never writes a manifest that it cannot later read. - -In v1, use ordinary manifest files, and update them in place. The tool does not create symbolic -links. Linked or redirected manifests fall outside the v1 safety guarantees. Do not replace an -existing manifest with a new file merely to handle links. That action can change file ownership -or access rights on Unix systems. - -**The manifest is a public contract.** Reports have no machine-readable form. Scripts read the -manifest instead, and teams commit the manifest to source control. Its shape follows -`dotnet-tools.json`. It has a format `version` property and a `packages` object. The object key -is the lowercase package ID. A change that an older version of the tool would misread needs a new -format `version` number. This way, an older tool refuses the file instead of misreading it. - -Write the manifest the same way on every platform. Use UTF-8 encoding without a byte order mark. -Use LF line endings, with a final newline at the end of the file. List packages and skills in a -fixed order. Escape values the same way on `net8.0` and `net10.0`. .NET 8 has no -`JsonWriterOptions.NewLine` property, so the writer replaces its CRLF output with LF after it -serializes the file. A byte that depends on the platform creates needless differences in a pull -request. - -**When no skill is tracked, the manifest and the folder both disappear.** When the last entry -leaves the manifest, `install` and `uninstall` both delete `.dotnet-package-skills.json`. They -also delete the destination folder, but only if the folder is empty. This way, a repository where -no package ships a skill never grows a stray `.agents/skills/` folder. The tool removes the -folder only when the folder is truly empty. Hand-written skills in that folder keep it from being -deleted. - -**Descriptions are read-only text for display. They are not a requirement for installation.** -Skill discovery still identifies a skill by its folder structure alone. Only the interactive -pickers read the top-level YAML `description` property in `SKILL.md`. They read it with a -bounded frontmatter reader and a standard YAML parser. Never interpret the Markdown body of -`SKILL.md`. Never run any part of its metadata as code. Never invent a description. Never -rewrite the file. - -When a skill has no description, the picker shows an explicit placeholder text. When the -metadata is unreadable or invalid, the picker shows a visible warning, but it still shows the -skill. This rule replaces an earlier rule that forbade reading frontmatter at all. The new rule -lets users make an informed choice. Reports and the manifest never include descriptions. - -**Treat skill names from packages as untrusted input.** A skill name becomes a path segment -inside the user's repository. `SkillDiscovery.IsSafeSkillName` is the one gate that both -discovery and the manifest use. Keep this gate strict. Reject a name that ends in a dot or a -space, on every platform. Windows can resolve such a name to a different folder, or to the -destination folder itself. Before you change any file, resolve every affected skill path as a -direct child of the destination folder. When the tool removes a skill, it must not walk up the -folder tree and delete a parent folder. - -**`install` removes a skill only when its package changes to a version that drops the skill.** A -noninteractive install offers the packages that it resolved and found in the cache. A tracked -skill is removed only when all of these conditions are true. First, the tool offers its package -at a different normalized version. Second, the new version does not ship that skill. Third, no -ownership conflict protects the skill. - -When an ownership conflict does protect the skill, `install` stops instead of removing it. -Removing the skill would hand its folder name to a different package. Relabeling the skill would -record the old version's files under the new version's name, and a later run would then never -remove those old files. A package that left the project is never a reason to delete its skills. -A package reference can disappear for a moment, for example during a refactor, so `install` -reports these skills as unreferenced instead of deleting them. `uninstall --stale` removes them -when the user asks for that. - -A version that is missing from the cache never causes a removal. When a target install is -missing a resolved package, the install stops before it writes anything, including during a -preview. An incomplete cache must never look like permission to delete skills. - -**`install -i` only adds skills. It never refreshes or removes a skill.** The checklist lists -only skills that would install cleanly and that are not already tracked. Nothing starts checked. -The installer receives an empty map of offered packages, so it cannot refresh or remove anything. - -Every check that could stop the run happens before the checklist opens. The checks look for -three problems: two versions of one package, a missing package or a stale skill when you use a -target, and a named package tracked at a different version when you use `--package`. If the tool -let you add a skill next to a stale skill, the manifest would disagree with the project. If the -tool let you add a skill next to another version of the same package, one package would have two -versions in the manifest. The tool stops before the checklist opens to prevent both problems. - -**`uninstall --stale` reads package references. It does not read packages.** This command needs -a solution or project. It compares the manifest with the target's direct package references from -`dotnet list package`. It never asks where the NuGet cache is. A missing or partial cache cannot -change what counts as stale, because the command never looks at the cache. A skill is stale when -no referenced package matches both its ID and its installed version. This rule also works -correctly when a target resolves a package to two versions. `--stale` cannot combine with -`--package`. `uninstall` accepts `--target` only together with `--stale`. - -**The picker shows one page at a time. This design choice is deliberate.** A solution can -reference many packages that ship skills. `SkillPicker` renders a frame that fits inside the -window, and it redraws that frame in place. The list can never scroll off the top of the screen -unread. We designed the picker this way to prevent the worst failure: a user who approves skills -that they never saw. Page size follows the rendered height of the content, including descriptions -and keyboard help. Page size does not follow a fixed count of items. The picker itself has no -access to the file system. `InteractiveSkills` supplies package descriptions for `install`, and it -supplies installed-file descriptions for `uninstall`. - -**The tool measures the frame from its content. The window bounds the frame's size.** A skill's -name and description share one row. The text ` - ` follows the authored name directly. The -layout does not use a padded name column. Do not append package or version metadata to a name. -Do not strip the package prefix from a name. Each skill's wrapped description text lines up with -the start of the skill's own text. The description uses the rest of the row's width. The -description does not use an indent as wide as the name. Measure every line, including every -wrapped footer line, before you assign whole skill entries to pages. A user must be able to -scroll an oversized description. The tool must never truncate a description silently. The layout -must reflow when the window resizes, and it must keep the current focus and the current -selections. A checkbox change or a cursor move must never shift a page boundary. A partial last -page ends at its actual content. It never shows a run of blank rows. - -**Keyboard hints follow the style of Aspire's checklist.** The primary hint reads -`(Press to select, to accept)`. It appears below the list. Use the same -angle-bracket key notation for paging, select-all, clear-all, cancel, and description scrolling. -Start every keyboard-help line with the word `Press`. Wrap the help text instead of cutting off -any key name. Show the paging and scrolling hints only when the user can actually use paging or -scrolling. - -**Focus and checked state are the only visual cues.** The focused skill's text turns blue, -including all of its wrapped description lines. A checked item shows a blue uppercase `X` in -both pickers. Selection never changes the color of a name or a bracket. Neither picker marks what -a check mark does with a separate cue. Each picker does only one thing, so its title and its -summary state that action. The uninstall summary also states how many skills will be removed. A -terminal without color shows `>` for focus and `[X]` for a checked item. It shows no other -marker and no color legend. The tool respects the `NO_COLOR` setting. A separate status column -would take space away from the descriptions, so the tool does not use one. - -**A check mark means install in one picker. It means remove in the other picker.** Neither -picker starts with any item checked. If a user presses enter without checking anything, both -pickers change nothing. `PickerMode` carries this difference, and the difference shows only in -the summary text. The code that calls the picker supplies the title text. The uninstall list -comes from the manifest, so the picker never offers a hand-written skill for deletion. - -**The picker takes control of the terminal, so it must give control back.** `Choose` hides the -cursor. It reads Ctrl+C as ordinary input instead of letting the runtime handle it. It restores -the cursor and the input mode inside a `finally` block. This matters because of Ctrl+C: if the -runtime handled Ctrl+C directly, it would end the process in the middle of a frame, the restore -code would never run, and the user would be left typing into a terminal with no visible cursor. -The code reads Ctrl+C as a key instead, and it cancels through the same path as the `esc` key. -The code tests for the Ctrl modifier before it checks the key itself, because a plain `c` key -clears the current selection. - -**An interactive choice applies only to the ownership state that was in effect when the user -made it.** The tool rechecks its ownership snapshot before it applies an interactive choice. When -ownership changed at the same time, the tool rejects the stale choice. It does not apply that -choice to a different package's files. - -Hold the destination lock from the moment the tool loads ownership information through the -moment it writes the final manifest. Both installation and removal take part in this lock. This -way, no other run of the tool can change ownership between the check and the change. Resolve -destination aliases to one canonical path before the tool chooses which lock to use. - -**A resize while a frame is on screen makes that frame invalid.** Read the new width and the new -height together. Restart the redraw when the viewport size changes. Clear cells in place instead -of scrolling blank lines past them. If the code scrolls blank lines instead, old picker frames -build up in the terminal's history, and they can wrap incorrectly when the host resizes again. -Keep the prior scrollback content, the current focus, and the current selections. Never swallow a -rendering failure that is not related to the resize. - -The picker owns an alternate screen for as long as it runs. Clearing only the current viewport -cannot erase old rows, because the host may have already moved those rows into its normal -history. Restore the original screen and the original output mode on every managed exit. Then -write the final report on the normal screen, not on the alternate screen. Clear the alternate -viewport and move the cursor to its home position before the first frame, and do the same thing -again after every resize. When the terminal enters the alternate screen, it can keep the shell's -old cursor position. If the code then reserves space with blank lines, those lines leave a gap -above a compact checklist. Never clear the normal screen to remove that gap. - -Every render also places the cursor directly below the last line that it drew. The cursor does -not go to the bottom of the layout's maximum possible height. `SkillPickerTests` checks this -exact placement for short pages. A managed exit restores the original shell cursor position on -its own, separately, when it leaves the alternate screen. - -**The picker's own chrome uses only ASCII characters. Author-written text can use any -language.** Keep every control hint and every marker in ASCII, so that an older console encoding -does not lose or corrupt them. Display Unicode descriptions without splitting a text element in -the middle. Measure terminal cells for this purpose, not UTF-16 code units. Remove any unsafe -terminal control sequence from text that a package author supplied. Send all color output -through `ITerminal`. The picker uses UTF-8 without a byte order mark while it prompts the user. -Restore the original text encoding and the original terminal styling when the picker exits, and -also when it fails. This way, ordinary command output keeps behaving the way it always did. - -**A report written for people must not let metadata run as a terminal command.** Sanitize every -untrusted display field with `TerminalText.Sanitize`. This includes package and version metadata, -file paths, reasons for a skip, and operational errors. Framework parser diagnostics and -suggestions use a separate output path. Sanitize that path too, including any write that is split -into parts. Keep multiline error guidance readable. - -Never sanitize an argument before the tool validates it. Never save a sanitized value as a -stored value. A canonical identity, such as a package ID, must stay intact and unmodified. - -**Reports exist for people to read. The tool has no JSON report.** The manifest is the one -machine-readable record. Exit codes carry success or failure. Do not add a `--json` report back -without a new discussion of that decision. `--package` accepts several values, so the parser -hands it any unknown option that follows it. This can happen when an old script still has a -`--json` flag left in it. The validator for `--package` reports a value that starts with `-` as -an unrecognized argument. It does not report that value as a malformed package coordinate. - -**`--package` refuses a floating version and refuses a version range.** Resolving a range means -choosing one version from it. The only correct answer to that choice comes from a project's own -restore step. `PackageCoordinate.Parse` is the one gate that enforces this rule. - -**The tool scans only direct dependencies.** Application code depends on its direct package -references. It does not depend on implementation details that come in through a transitive -reference. Do not add a `--include-transitive` option. Do not parse `transitivePackages`. Both -changes need a new product discussion first. - -**The authored skill folder name becomes the destination folder name.** A package skill at -`skills/contoso.widgets-widget-usage/` lands at `/contoso.widgets-widget-usage/`. -The package ID and the package version stay in the manifest as metadata. They do not become part -of the destination path. A skill must be an immediate subfolder that contains `SKILL.md`. A lone -`skills/SKILL.md` file, with no subfolder, is not supported. This is a deliberate choice. - -**When names collide, the tool warns and skips. It never overwrites a file silently.** The tool -compares destination names without regard to letter case. Package enumeration and skill discovery -both stay deterministic, so the same package always wins a collision, every time you run the -tool. An existing untracked destination folder belongs to the user. The tool must not touch it. -No install mode can transfer an already-tracked destination from one owner package to another -package. A conflicting path that the tool skips is also protected from removal when the owner's -version changes, in the same way that it is protected from copying. A version refresh of the -same package remains allowed. The tool never relabels a protected skill under a version that does -not ship it. That case stops the install instead, as an earlier rule in this list describes. - -Keep every discovery candidate available internally until the tool knows ownership at install -time. Prefer the current owner's candidate when more than one package offers the same name. -`list` stays a discovery report that does not depend on the destination folder's current state. - -Package authors can avoid collisions by prefixing their skill folders with their lowercased -package ID. The tool does not enforce this naming convention. - -In v1, the tool matches names case-insensitively, as a logical rule. It does not reconcile two -folders that differ only in physical case on a case-sensitive file system. Package authors must -keep their folder casing stable across versions. A case-only rename, or two physical folders -that differ only in case on a case-sensitive file system, both fall outside the v1 ownership -guarantees. Do not promise safe migration for these cases. Do not add special reconciliation -logic for them without a new discussion of this scope. - -**A package filter must never broaden a destructive operation.** An explicitly blank filter -value is an error. Only a completely absent `--package` option means all packages. Interactive -uninstall and noninteractive uninstall both use the same normalized version matcher. - -**The tool allows one version of each package, or it does not install at all.** The manifest -records exactly one version for each package. The resolved packages, or the `--package` -coordinates, can sometimes include two normalized versions of one package ID. When that happens, -every install mode stops before it changes anything, and it asks the user to align the versions. -We expect repositories to use NuGet Central Package Management for this. `PackageLister.Parse` -keeps each distinct `(id, version)` pair separate, so this check can see both versions. `list` -still shows both versions too. - -**Write every error message as guidance, not as a description of the failure alone.** Throw -`PackageSkillsException` with a message that tells the user what to do next. `Program.cs` prints -this message without a stack trace. If a message would leave a user stuck with no next step, add -more words to it. A command shown inside a message must work exactly as printed, when the user -copies and pastes it. Build that command with `SkillInstallService.UninstallCommand`. This -method repeats the run's `--target` value and its non-default `--destination` value. - -## Tests - -Application unit tests run offline. They never run the `dotnet` command. Any test code that -needs the CLI goes through `IProcessRunner`. `SkillInstallServiceTests` fakes this interface. See -`FakeDotnet` in that file for the pattern to follow. Use `TempDirectory` for any test that -touches the file system. `TempDirectory` cleans up its own files afterward. - -The interactive picker goes through `ITerminal`. `FakeTerminal` drives this interface from a -scripted sequence of keys, and it reads the result back as a screen buffer. `FakeTerminal` models -a buffer instead of joining writes end to end. The picker redraws its frame in place, so joining -every write together would stack frames on top of each other. A real user sees only one page at -a time, and the test model must match that. - -Arcade's standard `-test` action runs the application suite on both frameworks and publishes -the CI results. Keep tool-specific pipeline and signing configuration under -`eng\pipelines\dotnet-package-skills`. See its -[guide](../eng/pipelines/dotnet-package-skills/README.md) for versions and official prerequisites. - -### Name unit tests - -Name each test as a sentence that describes the behavior. Do not name a test after the method -under test: - -```csharp -[Fact] -public void Install_skips_a_later_skill_when_destination_names_collide() -``` - -Every new behavior needs a test. Every bug fix needs a test that fails without the fix. The -`.slnx` preference bug shipped with a test like this, and that test is the reason the bug has -stayed fixed since then. - -## Style - -`TreatWarningsAsErrors` is on. A build must produce no warnings. Beyond that rule, match the -style of the surrounding code. Write a comment to explain why the code does something. Do not -write a comment that only restates what the code does. If you find a comment that only restates -the code, delete it. - -## Compatibility - -- The tool targets `net8.0` and `net10.0`. Do not drop `net8.0` without a discussion first. Many - teams still run the `net8.0` long-term support release. -- `dotnet list package --format json` needs SDK 7.0.200 or later. This is the lowest SDK version - that the tool can inspect, and the error message states this requirement when the installed SDK - does not meet it. -- **The tool never restores a project.** It runs `dotnet list package` without a `--no-restore` - flag, and it never runs `dotnet restore` on its own. The .NET 10 SDK restores the project during - this listing step, when the project needs it. An earlier SDK instead reports that the target - needs to be restored first. When the listing fails, the command stops. It shows what the SDK - reported, including the JSON `problems` array when the SDK provides one, so the customer can - restore the target, or fix the target, and run the command again. `PackageListerTests` checks - two things together: the exact arguments that the tool passes, and the fact that the tool never - attempts a restore. -- The output shape of `dotnet nuget locals` has changed across SDK versions. The parser reads the - key from the `global-packages:` label in that output. It does not read the key by line - position. Keep the parser written this way. - -## Pull requests - -- Make one change in each pull request. -- Make sure the root Arcade build and both framework test runs pass. -- Update the README when you change the CLI surface. -- State what you tested your change against. For example, write "Ran `install` on a solution - with 40 packages. Two of those packages ship skills." This kind of statement is worth more than - a description of the code diff. From 1a6fcc1372bd0c6a72d8f601edb9c9cccbdd1d65 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Tue, 6 Oct 2026 14:37:54 -0700 Subject: [PATCH 08/10] Remove build instructions and license section Removed the 'Build from source' and 'License' sections from the README. --- dotnet-package-skills/README.md | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md index b12ca36..ae7d65b 100644 --- a/dotnet-package-skills/README.md +++ b/dotnet-package-skills/README.md @@ -579,31 +579,3 @@ explicitly. **A solution filter (`.slnf`) is rejected.** Not every SDK accepts a solution filter with `dotnet list package`. Pass the underlying `.sln` file instead, or run the tool once for each project with `--target`. - -## Build from source - -Run these commands from the root of a Client.Tools checkout. The root `global.json` selects the -pinned .NET SDK and Arcade SDK. The root `NuGet.Config` selects the public restore feeds. -Install PowerShell 7. Install the .NET 8 runtime into the same .NET installation that Arcade -uses. The SDK supplies the .NET 10 runtime. The CI system validates these commands on Windows. - -```powershell -eng\common\build.cmd -restore -build -test -pack -configuration Release -``` - -Arcade keeps the binaries under `artifacts\bin\DotnetPackageSkills\Release` and the package under -`artifacts\packages\Release\Shipping`. Local and public builds leave the package unsigned. -Official builds use Arcade to sign the nested assemblies, repack the package, and sign the -NuGet package. The original C# application tests run on both frameworks. - -A local build uses `0.1.0-dev`. A public CI or PR build uses `0.1.0-ci`. -An ordinary official build uses `0.1.0-beta..`. -Only an explicit manual release run from trusted internal main produces `0.1.0`. -The reviewed base version lives in `eng\Versions.props`. Builds publish pipeline artifacts, -not packages to a feed. See the -[pipeline and release guide](../eng/pipelines/dotnet-package-skills/README.md) for more -information. - -## License - -MIT From c9c12185f0569fa63a5c1a2de6fd1c87cbbd5e23 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Tue, 6 Oct 2026 17:00:15 -0700 Subject: [PATCH 09/10] Address focused package-skills review findings Preserve repeated escape introducers, validate and normalize consumed NuGet versions with NuGet.Versioning, reject linked manifest file entries, prune ignored and inaccessible target subtrees, and require nonempty native package staging. Add application regressions and update only affected behavior guidance; keep the minimal Arcade pipelines and bounded link-check scope. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet-package-skills/README.md | 27 +++- dotnet-package-skills/src/Cli/TerminalText.cs | 5 + .../src/DotnetPackageSkills.csproj | 1 + .../src/NuGet/PackageCoordinate.cs | 14 +- .../src/NuGet/PackageLister.cs | 13 +- .../src/NuGet/PackagePathResolver.cs | 61 +------- .../src/NuGet/TargetLocator.cs | 91 ++++++------ .../src/Skills/InstallManifest.cs | 32 +++- .../src/Skills/SkillInstaller.cs | 10 +- .../tests/CommandLineTests.cs | 13 ++ .../tests/InstallManifestTests.cs | 80 ++++++++++ .../tests/OutputWriterTests.cs | 12 +- .../tests/PackageCoordinateTests.cs | 18 +++ .../tests/PackageListerTests.cs | 38 +++++ .../tests/PackagePathResolverTests.cs | 24 ++- .../tests/SkillInstallServiceTests.cs | 87 +++++++++++ .../tests/SkillInstallerTests.cs | 87 +++++++++++ .../tests/TargetLocatorTests.cs | 138 ++++++++++++++++++ dotnet-package-skills/tests/TempDirectory.cs | 31 ++++ .../tests/TerminalTextTests.cs | 29 ++++ eng/pipelines/dotnet-package-skills/stage.yml | 11 +- 21 files changed, 690 insertions(+), 132 deletions(-) diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md index ae7d65b..ba3eebf 100644 --- a/dotnet-package-skills/README.md +++ b/dotnet-package-skills/README.md @@ -73,6 +73,11 @@ do not actually reference. `--target` and `--package` cannot combine, because both options answer the same question. +NuGet's version parser checks exact versions, including the older one-, two-, and four-part +forms. Invalid prerelease or metadata labels are rejected. Package comparisons normalize +padding and letter case and ignore build metadata, so `1.10` and `1.10.0+build.1` identify the +same release. Reports retain the version text that you supplied. + When you name packages explicitly, the tool touches only the packages you name. It leaves every other installed skill alone. A target describes the project's complete set of packages. Because of this, a target install can also tell you which installed skills belong to a package that the @@ -467,6 +472,11 @@ warning instead of blocking installation. ## How it works +Auto-detection checks the top level before nested targets, and prefers solutions over projects +within each stage. It never enters `bin`, `obj`, `.git`, `node_modules`, or `artifacts`, regardless +of letter case. It skips inaccessible children and directory links. An explicitly named target +directory can still be a link, but an unreadable requested directory produces an error. + 1. `dotnet list package --format json` finds the resolved direct packages. The tool never restores a project on its own. The .NET 10 SDK restores the project during this step, when it needs to. An earlier SDK instead says that the target needs to be restored first. When @@ -500,8 +510,8 @@ skill from every other repository that uses that package. `.dotnet-package-skills.json` records what the tool copied in. `install` removes only the paths listed there, when a package moves to a new version. `uninstall` removes only those listed paths too. Neither command scans arbitrary folders. Keep hand-written guidance in a separate, untracked -folder. This folder is still subject to the v1 case-variant and linked-manifest limitations that -this document describes. +folder. This folder is still subject to the v1 case-variant limitations that this document +describes. When that manifest exists but the tool cannot read it, `install` and `uninstall` both stop without changing anything. They keep the file in place so you can repair it. Resolve any merge @@ -512,7 +522,8 @@ not guess which existing folders it owns. The tool also refuses a manifest in three other cases. It refuses a manifest that names a newer format version. Update the tool instead. It refuses a manifest that a pre-release build of this tool wrote. Move the skills folder aside and install again instead. It refuses a manifest where a -package is missing its version, where a package ID is invalid, or where a skill is claimed twice. +package has an invalid or missing exact version, where a package ID is invalid, or where a skill +is claimed twice. A skill name must identify a single folder directly inside the destination. The tool rejects a name that ends in a dot or a space, including the name `...`, because Windows can resolve such a @@ -521,11 +532,11 @@ name blocks both install and uninstall, including interactive mode and dry-run m tool changes any skill file or manifest byte. By default, the tool creates an ordinary manifest file, and it updates an existing manifest in -place. V1 does not support a symbolic link or another kind of redirected manifest. The tool does -not create such a link, and it does not protect a link's target. An ordinary file system -operation can follow a link, including a link that already exists in a checked-out repository. -Use a regular manifest file in your skills destination. A customer who provides a link is -responsible for that link's effects. +place. It rejects a manifest file that is a symbolic link or reparse point, including a dangling +link, before install or uninstall changes skills. Use a regular manifest file instead. +A destination directory reached through a link or junction remains supported. This check +is limited to the manifest file entry; it does not guarantee safety against a link replaced +concurrently between checking and writing. The tool serializes concurrent operations on the same destination. It rejects an interactive choice if ownership changed before the tool could apply that choice. diff --git a/dotnet-package-skills/src/Cli/TerminalText.cs b/dotnet-package-skills/src/Cli/TerminalText.cs index ab12e14..47007c9 100644 --- a/dotnet-package-skills/src/Cli/TerminalText.cs +++ b/dotnet-package-skills/src/Cli/TerminalText.cs @@ -191,6 +191,11 @@ private static int SkipEscape(string text, int index) return index; } + if (text[index] == '\x1b') + { + return index; + } + kind = text[index++]; } diff --git a/dotnet-package-skills/src/DotnetPackageSkills.csproj b/dotnet-package-skills/src/DotnetPackageSkills.csproj index ac20ae2..20d2299 100644 --- a/dotnet-package-skills/src/DotnetPackageSkills.csproj +++ b/dotnet-package-skills/src/DotnetPackageSkills.csproj @@ -31,6 +31,7 @@ + diff --git a/dotnet-package-skills/src/NuGet/PackageCoordinate.cs b/dotnet-package-skills/src/NuGet/PackageCoordinate.cs index 91767ce..3e1c113 100644 --- a/dotnet-package-skills/src/NuGet/PackageCoordinate.cs +++ b/dotnet-package-skills/src/NuGet/PackageCoordinate.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using NuGet.Versioning; namespace DotnetPackageSkills.NuGet; @@ -64,13 +65,20 @@ public static PackageCoordinate Parse(string value) """); } - if (!ExactVersionPattern().IsMatch(version)) + ParseVersion(version); + + return new PackageCoordinate(id, version); + } + + internal static NuGetVersion ParseVersion(string version) + { + if (!NuGetVersion.TryParse(version, out var parsed)) { throw new PackageSkillsException( $"'{version}' is not a version this tool recognises. Expected something like 1.10.0 or 2.0.0-beta.1."); } - return new PackageCoordinate(id, version); + return parsed; } internal static void ValidateId(string id) @@ -99,6 +107,4 @@ internal static void ValidateId(string id) [GeneratedRegex(@"^\w+([.-]\w+)*\z", RegexOptions.CultureInvariant)] private static partial Regex PackageIdPattern(); - [GeneratedRegex(@"^\d+(\.\d+){0,3}(-[0-9A-Za-z][0-9A-Za-z.-]*)?(\+[0-9A-Za-z][0-9A-Za-z.-]*)?$")] - private static partial Regex ExactVersionPattern(); } diff --git a/dotnet-package-skills/src/NuGet/PackageLister.cs b/dotnet-package-skills/src/NuGet/PackageLister.cs index 2a73fd5..e7e3a67 100644 --- a/dotnet-package-skills/src/NuGet/PackageLister.cs +++ b/dotnet-package-skills/src/NuGet/PackageLister.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using DotnetPackageSkills.Infrastructure; +using NuGet.Versioning; namespace DotnetPackageSkills.NuGet; @@ -110,12 +111,20 @@ internal static IReadOnlyList Parse(string json) // floating version or a version managed through Central Package Management. var version = Coalesce(entry.ResolvedVersion, entry.RequestedVersion); - if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(version)) + if (string.IsNullOrEmpty(id)) { continue; } - var key = (id.ToLowerInvariant(), version.ToLowerInvariant()); + PackageCoordinate.ValidateId(id); + if (version is null || !NuGetVersion.TryParse(version, out _)) + { + throw new PackageSkillsException( + $"'dotnet list package' reported an invalid exact version '{version}' for package '{id}'. " + + "Restore or fix the target's package references, then try again."); + } + + var key = (id.ToLowerInvariant(), PackagePathResolver.NormalizeVersion(version)); found[key] = new PackageReferenceInfo(id, version); } } diff --git a/dotnet-package-skills/src/NuGet/PackagePathResolver.cs b/dotnet-package-skills/src/NuGet/PackagePathResolver.cs index e0a826e..7390b65 100644 --- a/dotnet-package-skills/src/NuGet/PackagePathResolver.cs +++ b/dotnet-package-skills/src/NuGet/PackagePathResolver.cs @@ -6,14 +6,15 @@ namespace DotnetPackageSkills.NuGet; /// /// Restore extracts each package to <global-packages>/<id>/<version>/ /// with both segments lowercased and the version normalized. This mirrors NuGet's own -/// normalization rules; where they are ambiguous, a directory scan settles it, so a -/// mismatch degrades into a slower lookup rather than a missed package. +/// normalization rules. A case-insensitive directory scan also supports older cache +/// layouts that retained an original valid version spelling. /// public static class PackagePathResolver { /// Returns the extracted package folder, or null when it is not on disk. public static string? Resolve(string globalPackagesFolder, string packageId, string version) { + var normalized = NormalizeVersion(version); var packageDirectory = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant()); if (!Directory.Exists(packageDirectory)) @@ -21,17 +22,13 @@ public static class PackagePathResolver return null; } - var normalized = NormalizeVersion(version); - var candidate = Path.Combine(packageDirectory, normalized); if (Directory.Exists(candidate)) { return candidate; } - // Fall back to a case-insensitive scan. NuGet's normalization has corner cases - // (SemVer 2 build metadata, unusual padding) that are not worth reimplementing - // exactly, and the directory itself is the authoritative answer. + // Older cache layouts may retain the original spelling of a valid version. foreach (var directory in Directory.EnumerateDirectories(packageDirectory)) { var name = Path.GetFileName(directory); @@ -50,52 +47,6 @@ public static class PackagePathResolver /// metadata dropped, padded to three parts, and a fourth part dropped when zero. /// So 1.2 becomes 1.2.0 and 1.2.3.0 becomes 1.2.3. /// - public static string NormalizeVersion(string version) - { - var value = version.Trim(); - - // Build metadata is not part of package identity and never appears in the path. - var plus = value.IndexOf('+'); - if (plus >= 0) - { - value = value[..plus]; - } - - var dash = value.IndexOf('-'); - var core = dash >= 0 ? value[..dash] : value; - var prerelease = dash >= 0 ? value[(dash + 1)..] : string.Empty; - - var parts = core.Split('.'); - var numbers = new List(4); - - foreach (var part in parts) - { - if (!int.TryParse(part, out var number) || number < 0) - { - // Not a version shape this tool understands; leave it to the directory scan. - return version.Trim().ToLowerInvariant(); - } - - numbers.Add(number); - } - - while (numbers.Count < 3) - { - numbers.Add(0); - } - - if (numbers.Count >= 4 && numbers[3] == 0) - { - numbers.RemoveRange(3, numbers.Count - 3); - } - - var normalized = string.Join('.', numbers); - - if (prerelease.Length > 0) - { - normalized = $"{normalized}-{prerelease}"; - } - - return normalized.ToLowerInvariant(); - } + public static string NormalizeVersion(string version) => + PackageCoordinate.ParseVersion(version).ToNormalizedString().ToLowerInvariant(); } diff --git a/dotnet-package-skills/src/NuGet/TargetLocator.cs b/dotnet-package-skills/src/NuGet/TargetLocator.cs index 1d6b649..3eeb00d 100644 --- a/dotnet-package-skills/src/NuGet/TargetLocator.cs +++ b/dotnet-package-skills/src/NuGet/TargetLocator.cs @@ -54,66 +54,75 @@ public static string Detect(string directory) throw new PackageSkillsException($"Directory does not exist: {directory}"); } - foreach (var extensions in new[] { SolutionExtensions, ProjectExtensions }) - { - var match = EnumerateFiles(directory, extensions, SearchOption.TopDirectoryOnly).FirstOrDefault(); - if (match is not null) - { - return match; - } - } + var (topLevel, children) = ReadDirectory(directory, explicitRoot: true); + if (BestTarget(topLevel) is { } rootTarget) { return rootTarget; } - foreach (var extensions in new[] { SolutionExtensions, ProjectExtensions }) + var nested = new List(); + var pending = new Stack(children); + while (pending.TryPop(out var child)) { - var match = EnumerateFiles(directory, extensions, SearchOption.AllDirectories) - .Where(path => !IsIgnored(path, directory)) - .FirstOrDefault(); - if (match is not null) - { - return match; - } + var (files, directories) = ReadDirectory(child, explicitRoot: false); + nested.AddRange(files); + foreach (var descendant in directories) { pending.Push(descendant); } } + if (BestTarget(nested) is { } nestedTarget) { return nestedTarget; } + throw new PackageSkillsException( $"No solution or project found under {directory}. " + "Pass one explicitly, for example: --target src/MyApp.sln"); } - private static IEnumerable EnumerateFiles(string directory, string[] extensions, SearchOption option) + private static (List Files, List Children) ReadDirectory(string directory, bool explicitRoot) { - IEnumerable files; + var files = new List(); + var children = new List(); try { - files = Directory.EnumerateFiles(directory, "*", option); + foreach (var entry in new DirectoryInfo(directory).EnumerateFileSystemInfos()) + { + if (entry is DirectoryInfo child) + { + if (!IgnoredDirectories.Contains(child.Name, StringComparer.OrdinalIgnoreCase) && + (child.Attributes & FileAttributes.ReparsePoint) == 0) + { + children.Add(child.FullName); + } + } + else if (Rank(entry.FullName) >= 0) + { + files.Add(entry.FullName); + } + } } - catch (UnauthorizedAccessException) + catch (UnauthorizedAccessException error) { - return []; + if (explicitRoot) + { + throw new PackageSkillsException( + $"Could not read target directory '{directory}'. Check its permissions or pass a readable --target.", + error); + } + + return ([], []); } + catch (DirectoryNotFoundException) when (!explicitRoot) { return ([], []); } - // Rank by the extension's position in the list, so preference between formats - // (.slnx ahead of .sln) is not left to how the file names happen to sort. - return files - .Select(file => new - { - File = file, - Rank = Array.FindIndex( - extensions, - extension => extension.Equals(Path.GetExtension(file), StringComparison.OrdinalIgnoreCase)), - }) - .Where(candidate => candidate.Rank >= 0) - .OrderBy(candidate => candidate.Rank) - .ThenBy(candidate => candidate.File, StringComparer.Ordinal) - .Select(candidate => candidate.File); + return (files, children); } - private static bool IsIgnored(string path, string root) + private static string? BestTarget(IEnumerable files) => + files.OrderBy(Rank).ThenBy(file => file, StringComparer.Ordinal).FirstOrDefault(); + + private static int Rank(string file) { - var relative = Path.GetRelativePath(root, path); - var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var extension = Path.GetExtension(file); + var solution = Array.FindIndex(SolutionExtensions, + candidate => candidate.Equals(extension, StringComparison.OrdinalIgnoreCase)); + if (solution >= 0) { return solution; } - // The file name itself is never a directory match. - return segments.Take(segments.Length - 1) - .Any(segment => IgnoredDirectories.Contains(segment, StringComparer.OrdinalIgnoreCase)); + var project = Array.FindIndex(ProjectExtensions, + candidate => candidate.Equals(extension, StringComparison.OrdinalIgnoreCase)); + return project < 0 ? -1 : SolutionExtensions.Length + project; } } diff --git a/dotnet-package-skills/src/Skills/InstallManifest.cs b/dotnet-package-skills/src/Skills/InstallManifest.cs index 5f25df8..23ed2c7 100644 --- a/dotnet-package-skills/src/Skills/InstallManifest.cs +++ b/dotnet-package-skills/src/Skills/InstallManifest.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using DotnetPackageSkills.NuGet; +using NuGet.Versioning; namespace DotnetPackageSkills.Skills; @@ -50,6 +51,7 @@ public sealed class InstallManifest public static InstallManifest Load(string destinationRoot) { var path = Path.Combine(destinationRoot, FileName); + RequireRegularManifest(path); if (!File.Exists(path)) { @@ -145,6 +147,11 @@ private static InstallManifest Read(JsonElement root, string path) throw CannotRead(path, $"'packages.{id}.version' must be text"); } + if (!NuGetVersion.TryParse(version.GetString(), out _)) + { + throw CannotRead(path, $"'packages.{id}.version' must be an exact NuGet version"); + } + if (!TryGetProperty(package.Value, "skills", out var skills) || skills.ValueKind != JsonValueKind.Array) { throw CannotRead(path, $"'packages.{id}.skills' must be an array"); @@ -249,11 +256,13 @@ private static PackageSkillsException WrittenByPreRelease(string path) => public void Save(string destinationRoot) { + var path = Path.Combine(destinationRoot, FileName); + RequireRegularManifest(path); Directory.CreateDirectory(destinationRoot); // Rewrite the existing file rather than replacing it, which keeps its permissions. File.WriteAllText( - Path.Combine(destinationRoot, FileName), + path, Serialize(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } @@ -340,9 +349,30 @@ .. group public static void Delete(string destinationRoot) { var path = Path.Combine(destinationRoot, FileName); + RequireRegularManifest(path); if (File.Exists(path)) { File.Delete(path); } } + + private static void RequireRegularManifest(string path) + { + try + { + if (new FileInfo(path).LinkTarget is not null || + (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new PackageSkillsException( + $"The install manifest '{path}' must be a regular file, not a symbolic link or reparse point. " + + "Replace the manifest link with a regular file before trying again."); + } + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + throw CannotRead(path, "its file entry could not be inspected", error); + } + } } diff --git a/dotnet-package-skills/src/Skills/SkillInstaller.cs b/dotnet-package-skills/src/Skills/SkillInstaller.cs index 1fbb374..a1986f5 100644 --- a/dotnet-package-skills/src/Skills/SkillInstaller.cs +++ b/dotnet-package-skills/src/Skills/SkillInstaller.cs @@ -1,4 +1,5 @@ using DotnetPackageSkills.NuGet; +using NuGet.Versioning; namespace DotnetPackageSkills.Skills; @@ -48,6 +49,8 @@ public InstallOutcome Install( .ToDictionary(group => group.Key, group => group.First().PackageVersion, StringComparer.OrdinalIgnoreCase) : new Dictionary(offered, StringComparer.OrdinalIgnoreCase); + foreach (var version in versions.Values) { PackageCoordinate.ParseVersion(version); } + using var destinationLock = DestinationLock.Acquire(destinationRoot); var manifest = InstallManifest.Load(destinationRoot); var trackedSkills = manifest.EnumerateSkills().ToList(); @@ -187,7 +190,7 @@ public InstallOutcome Install( } internal static bool SameVersion(string left, string right) => - PackagePathResolver.NormalizeVersion(left).Equals(PackagePathResolver.NormalizeVersion(right), StringComparison.Ordinal); + VersionComparer.VersionRelease.Equals(PackageCoordinate.ParseVersion(left), PackageCoordinate.ParseVersion(right)); private static PackageSkillsException NameWouldChangeOwner( IReadOnlyList stranded, @@ -248,6 +251,8 @@ public IReadOnlyList Uninstall( IReadOnlyCollection? expectedInstalled = null, IReadOnlyCollection? staleAgainst = null) { + if (packageVersion is not null) { PackageCoordinate.ParseVersion(packageVersion); } + using var destinationLock = DestinationLock.Acquire(destinationRoot); var manifest = InstallManifest.Load(destinationRoot); @@ -301,8 +306,7 @@ internal static bool Matches(TrackedSkill entry, string? packageId, string? pack // Compare normalized, so 1.2 and 1.2.0 identify the same installed folder. return packageVersion is null || - PackagePathResolver.NormalizeVersion(entry.Version) - .Equals(PackagePathResolver.NormalizeVersion(packageVersion), StringComparison.OrdinalIgnoreCase); + SameVersion(entry.Version, packageVersion); } private static void CheckOwnershipSnapshot( diff --git a/dotnet-package-skills/tests/CommandLineTests.cs b/dotnet-package-skills/tests/CommandLineTests.cs index 768fbc9..5ecadae 100644 --- a/dotnet-package-skills/tests/CommandLineTests.cs +++ b/dotnet-package-skills/tests/CommandLineTests.cs @@ -62,6 +62,7 @@ public void Uninstall_accepts_valid_underscore_boundary_package_ids(string id) [InlineData("1.2", "1.2.0")] [InlineData("1.2.0.0", "1.2.0")] [InlineData("1.2.0-RC.1", "1.2.0-rc.1")] + [InlineData("01.02.0-BETA.1+Build.A", "1.2-beta.1+Build.B")] public void Uninstall_filter_matching_normalizes_versions_for_both_modes(string filterVersion, string installedVersion) { var (id, version) = CommandLineBuilder.ParseUninstallFilter($"mockly@{filterVersion}"); @@ -70,6 +71,18 @@ public void Uninstall_filter_matching_normalizes_versions_for_both_modes(string Assert.False(SkillInstaller.Matches(new TrackedSkill("Other", installedVersion, "usage"), id, version)); } + [Theory] + [InlineData("1.0.0-alpha.")] + [InlineData("1.0.0+a..b")] + [InlineData("2147483648.0.0")] + [InlineData("1.2.3.4.5")] + public void Invalid_exact_versions_cannot_be_uninstall_filters_in_either_mode(string version) + { + Assert.Throws(() => CommandLineBuilder.ParseUninstallFilter($"Mockly@{version}")); + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", "--package", $"Mockly@{version}"]).Errors); + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", "-i", "--package", $"Mockly@{version}"]).Errors); + } + [Fact] public void Uninstall_accepts_the_interactive_flag() { diff --git a/dotnet-package-skills/tests/InstallManifestTests.cs b/dotnet-package-skills/tests/InstallManifestTests.cs index c58608d..f3271fc 100644 --- a/dotnet-package-skills/tests/InstallManifestTests.cs +++ b/dotnet-package-skills/tests/InstallManifestTests.cs @@ -126,6 +126,50 @@ public void One_package_cannot_be_recorded_at_two_versions() Assert.Empty(manifest.EnumerateSkills()); } + [Theory] + [InlineData("1.0.0-alpha.")] + [InlineData("1.0.0+a..b")] + [InlineData("1.2.3.4.5")] + [InlineData("2147483648.0.0")] + [InlineData("1.*")] + [InlineData("[1.0,2.0)")] + public void Invalid_stored_versions_fail_before_reading_or_writing_ownership(string version) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var contents = $$$""" + {"version":1,"packages":{ + "mockly":{"version":"{{{version}}}","skills":["usage"]} + }} + """; + var path = temp.CreateFile("dest/.dotnet-package-skills.json", contents); + + var error = Assert.Throws(() => InstallManifest.Load(destination)); + Assert.Contains("Could not read the install manifest", error.Message); + Assert.Contains("mockly", error.Message); + Assert.Contains("version", error.Message); + Assert.Equal(contents, File.ReadAllText(path)); + + var manifest = new InstallManifest(); + Assert.Throws(() => + manifest.SetSkills([new TrackedSkill("Mockly", version, "usage")])); + Assert.Empty(manifest.EnumerateSkills()); + } + + [Fact] + public void NuGet_metadata_and_padding_do_not_create_two_manifest_versions() + { + var manifest = new InstallManifest(); + manifest.SetSkills( + [ + new TrackedSkill("Mockly", "01.10.00.0-BETA.1+Build.A", "first"), + new TrackedSkill("mockly", "1.10-beta.1+Build.B", "second"), + ]); + + Assert.Equal("1.10.0-beta.1", manifest.Packages["mockly"].Version); + Assert.Equal(["first", "second"], manifest.Packages["mockly"].Skills); + } + [Fact] public void A_package_id_that_the_reader_would_refuse_is_never_written() { @@ -294,6 +338,42 @@ public void Saving_a_regular_manifest_updates_it_without_creating_temporary_file Assert.Null(new FileInfo(path).LinkTarget); } + [SymbolicLinkTheory] + [InlineData("load", false)] + [InlineData("save", false)] + [InlineData("delete", false)] + [InlineData("load", true)] + [InlineData("save", true)] + [InlineData("delete", true)] + public void Linked_manifest_entries_are_rejected_including_dangling_links(string operation, bool dangling) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var target = temp.Combine("outside.json"); + if (!dangling) { File.WriteAllText(target, Contents); } + var path = Path.Combine(destination, InstallManifest.FileName); + File.CreateSymbolicLink(path, target); + try + { + var error = Assert.Throws(() => + { + switch (operation) + { + case "load": InstallManifest.Load(destination); break; + case "save": new InstallManifest().Save(destination); break; + case "delete": InstallManifest.Delete(destination); break; + } + }); + + Assert.Contains("regular", error.Message); + Assert.Contains(path, error.Message); + Assert.Equal(target, new FileInfo(path).LinkTarget); + if (dangling) { Assert.False(File.Exists(target)); } + else { Assert.Equal(Contents, File.ReadAllText(target)); } + } + finally { File.Delete(path); } + } + [Fact] public void A_read_only_manifest_is_not_silently_overwritten() { diff --git a/dotnet-package-skills/tests/OutputWriterTests.cs b/dotnet-package-skills/tests/OutputWriterTests.cs index 95f969d..2aff491 100644 --- a/dotnet-package-skills/tests/OutputWriterTests.cs +++ b/dotnet-package-skills/tests/OutputWriterTests.cs @@ -335,13 +335,12 @@ public void An_unterminated_control_in_one_identity_field_cannot_hide_the_follow } [Fact] - public void A_manifest_clipboard_payload_is_safe_to_preview_without_rewriting_identity_or_files() + public void A_manifest_clipboard_payload_is_rejected_without_rewriting_identity_or_files() { using var temp = new TempDirectory(); var destination = temp.CreateDirectory("dest"); var skillFile = temp.CreateFile("dest/example-skill/SKILL.md", "installed guidance"); var handwritten = temp.CreateFile("dest/our-own-skill/SKILL.md", "handwritten guidance"); - // Package ids are validated when the manifest is read, so the version carries the payload. var version = "1.0.0" + ClipboardControl; var manifest = temp.CreateFile("dest/.dotnet-package-skills.json", JsonSerializer.Serialize(new { @@ -352,15 +351,14 @@ public void A_manifest_clipboard_payload_is_safe_to_preview_without_rewriting_id }, })); var before = File.ReadAllBytes(manifest); - var removed = new SkillInstaller().Uninstall(destination, null, null, dryRun: true); + var error = Assert.Throws(() => + new SkillInstaller().Uninstall(destination, null, null, dryRun: true)); using var text = new StringWriter(); - new OutputWriter(text).WriteUninstallReport(removed, destination, dryRun: true); + new OutputWriter(text, text).WriteError(error.Message); AssertPlainText(text.ToString()); - Assert.Contains("example-skill (example 1.0.0)", text.ToString()); - Assert.Equal("example", Assert.Single(removed).Package); - Assert.Equal(version, Assert.Single(removed).Version); + Assert.Contains("exact NuGet version", text.ToString()); Assert.Equal(before, File.ReadAllBytes(manifest)); Assert.Equal("installed guidance", File.ReadAllText(skillFile)); Assert.Equal("handwritten guidance", File.ReadAllText(handwritten)); diff --git a/dotnet-package-skills/tests/PackageCoordinateTests.cs b/dotnet-package-skills/tests/PackageCoordinateTests.cs index 0b1b890..7c2ab37 100644 --- a/dotnet-package-skills/tests/PackageCoordinateTests.cs +++ b/dotnet-package-skills/tests/PackageCoordinateTests.cs @@ -11,7 +11,9 @@ public class PackageCoordinateTests [InlineData("_Acme@1.0.0", "_Acme", "1.0.0")] [InlineData("Acme_@1.0.0", "Acme_", "1.0.0")] [InlineData("Widgets@2.0", "Widgets", "2.0")] + [InlineData("Widgets@1", "Widgets", "1")] [InlineData("Widgets@1.2.3.4", "Widgets", "1.2.3.4")] + [InlineData("Widgets@01.02.003.0-BETA.1+build.001", "Widgets", "01.02.003.0-BETA.1+build.001")] [InlineData("Widgets@1.2.3+sha.abc", "Widgets", "1.2.3+sha.abc")] [InlineData(" Mockly@1.10.0 ", "Mockly", "1.10.0")] [InlineData("Contoso.Überlib@1.0.0", "Contoso.Überlib", "1.0.0")] @@ -56,6 +58,17 @@ public void Parse_tells_the_user_how_to_add_a_missing_version() [InlineData("Mockly@@1.0.0")] [InlineData("Mockly@not-a-version")] [InlineData("Mockly@v1.0.0")] + [InlineData("Mockly@1.0.0-alpha.")] + [InlineData("Mockly@1.0.0+a..b")] + [InlineData("Mockly@1.0.0-")] + [InlineData("Mockly@1.0.0+")] + [InlineData("Mockly@1.0.0-a..b")] + [InlineData("Mockly@1.0.0-.alpha")] + [InlineData("Mockly@1.0.0+a.")] + [InlineData("Mockly@1.0.0-beta.01")] + [InlineData("Mockly@1.2.3.4.5")] + [InlineData("Mockly@2147483648.0.0")] + [InlineData("Mockly@1.0.0.2147483648")] [InlineData("../evil@1.0.0")] [InlineData("path/to/thing@1.0.0")] [InlineData("Contoso..Widgets@1.0.0")] @@ -67,4 +80,9 @@ public void Parse_rejects_malformed_input(string input) => [Fact] public void ToString_round_trips() => Assert.Equal("Mockly@1.10.0", PackageCoordinate.Parse("Mockly@1.10.0").ToString()); + + [Fact] + public void Display_retains_the_trimmed_original_coordinate_spelling() => + Assert.Equal("Mockly@01.10-BETA.1+Build.01", + PackageCoordinate.Parse(" Mockly @ 01.10-BETA.1+Build.01 ").ToString()); } diff --git a/dotnet-package-skills/tests/PackageListerTests.cs b/dotnet-package-skills/tests/PackageListerTests.cs index d7e2502..f18cd53 100644 --- a/dotnet-package-skills/tests/PackageListerTests.cs +++ b/dotnet-package-skills/tests/PackageListerTests.cs @@ -150,6 +150,44 @@ public void Parse_keeps_both_versions_when_frameworks_resolve_a_package_differen Assert.Equal(["1.0.0", "2.0.0"], packages.Select(p => p.Version)); } + [Theory] + [InlineData("1.10", "1.10.0")] + [InlineData("01.02.003.0-BETA.1+Build.A", "1.2.3-beta.1+build.B")] + public void Parse_deduplicates_equivalent_NuGet_versions(string first, string second) + { + var json = $$""" + {"projects":[{"frameworks":[{"topLevelPackages":[ + {"id":"Mockly","resolvedVersion":"{{first}}"}, + {"id":"mockly","resolvedVersion":"{{second}}"} + ]}]}]} + """; + + Assert.Single(PackageLister.Parse(json)); + } + + [Theory] + [InlineData("1.0.0-alpha.")] + [InlineData("1.0.0+a..b")] + [InlineData("1.*")] + [InlineData("[1.0,2.0)")] + [InlineData("2147483648.0.0")] + [InlineData("1.2.3.4.5")] + [InlineData("")] + public void Parse_reports_invalid_SDK_versions_instead_of_missing_cache_packages(string version) + { + var json = $$""" + {"projects":[{"frameworks":[{"topLevelPackages":[ + {"id":"Mockly","resolvedVersion":"{{version}}"} + ]}]}]} + """; + + var error = Assert.Throws(() => PackageLister.Parse(json)); + + Assert.Contains("Mockly", error.Message); + Assert.Contains("version", error.Message); + Assert.Contains("dotnet list", error.Message); + } + [Fact] public void Parse_prefers_the_resolved_version_over_the_requested_one() { diff --git a/dotnet-package-skills/tests/PackagePathResolverTests.cs b/dotnet-package-skills/tests/PackagePathResolverTests.cs index 698e5cf..4a65935 100644 --- a/dotnet-package-skills/tests/PackagePathResolverTests.cs +++ b/dotnet-package-skills/tests/PackagePathResolverTests.cs @@ -25,9 +25,22 @@ public class PackagePathResolverTests public void NormalizeVersion_matches_NuGet_folder_naming(string input, string expected) => Assert.Equal(expected, PackagePathResolver.NormalizeVersion(input)); - [Fact] - public void NormalizeVersion_leaves_unparseable_versions_alone_for_the_directory_scan() => - Assert.Equal("1.x.3", PackagePathResolver.NormalizeVersion("1.X.3")); + [Theory] + [InlineData("1.X.3")] + [InlineData("1.2.3.4.5")] + [InlineData("1.0.0-alpha.")] + [InlineData("1.0.0+a..b")] + [InlineData("2147483648.0.0")] + [InlineData("1.*")] + [InlineData("[1.0,2.0)")] + public void Invalid_versions_are_rejected_before_cache_lookup(string version) + { + using var temp = new TempDirectory(); + + Assert.Throws(() => PackagePathResolver.NormalizeVersion(version)); + Assert.Throws(() => + PackagePathResolver.Resolve(temp.Combine("missing-cache"), "Mockly", version)); + } [Fact] public void Resolve_finds_the_lowercased_folder_for_a_mixed_case_package_id() @@ -54,10 +67,9 @@ public void Resolve_falls_back_to_scanning_when_normalization_does_not_match() { using var temp = new TempDirectory(); - // A folder name our rules would not produce, so only the scan can find it. - temp.CreateDirectory("packages", "oddball", "1.2.3.4.5"); + temp.CreateDirectory("packages", "oddball", "1.02-BETA+Build.99"); - Assert.NotNull(PackagePathResolver.Resolve(temp.Combine("packages"), "Oddball", "1.2.3.4.5")); + Assert.NotNull(PackagePathResolver.Resolve(temp.Combine("packages"), "Oddball", "1.02-beta+build.99")); } [Fact] diff --git a/dotnet-package-skills/tests/SkillInstallServiceTests.cs b/dotnet-package-skills/tests/SkillInstallServiceTests.cs index 01bbc0a..b68b963 100644 --- a/dotnet-package-skills/tests/SkillInstallServiceTests.cs +++ b/dotnet-package-skills/tests/SkillInstallServiceTests.cs @@ -62,6 +62,93 @@ private static string Json(params (string Id, string Version)[] packages) GlobalPackagesOverride = temp.Combine("packages"), }; + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Invalid_SDK_versions_stop_each_install_mode_before_files_change(bool interactive, bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.0.0", "usage"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.0.0")))) + .Install(Request(temp)); + var before = Snapshot(temp.Combine(".agents", "skills")); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.0.0-alpha.")))); + var request = Request(temp) with { DryRun = dryRun }; + + var error = Assert.Throws(() => + { + var discovered = service.Discover(request); + if (interactive) + { + service.PrepareInteractiveInstall(request, discovered, + SkillInstallService.InstalledSkills(discovered.Destination, temp.Path)); + } + else + { + service.Install(request, discovered, null); + } + }); + + Assert.Contains("invalid exact version", error.Message); + Assert.DoesNotContain("missing from", error.Message); + Assert.Equal(before, Snapshot(temp.Combine(".agents", "skills"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Equivalent_reference_versions_do_not_conflict_or_make_skills_stale(bool interactive) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0-beta.1", "usage", "second"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), + Json(("Mockly", "01.10.0.0-BETA.1+Build.A"), ("mockly", "1.10-beta.1+Build.B")))); + var request = Request(temp); + var discovered = service.Discover(request); + Assert.Equal(1, discovered.PackagesScanned); + service.Install(request, discovered, new SkillChoice([discovered.Skills[0]])); + var installed = SkillInstallService.InstalledSkills(discovered.Destination, temp.Path); + + var result = interactive + ? service.PrepareInteractiveInstall(request, service.Discover(request), installed) + : service.Install(request); + + Assert.Empty(result.Removed); + Assert.Empty(result.Unreferenced); + Assert.Empty(service.Uninstall(".agents/skills", temp.Path, null, null, dryRun: true, + staleAgainst: [new PackageReferenceInfo("Mockly", "1.10.0-beta.1+Build.C")])); + Assert.Single(service.Uninstall(".agents/skills", temp.Path, "MOCKLY", "1.10-BETA.1+Build.D", + dryRun: true, only: [installed[0].Skill])); + } + + [SymbolicLinkTheory] + [InlineData(false)] + [InlineData(true)] + public void Interactive_ownership_reads_reject_linked_manifests_before_a_picker(bool dangling) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory(".agents", "skills"); + var target = temp.Combine("outside.json"); + const string contents = """{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":["usage"]}}}"""; + if (!dangling) { File.WriteAllText(target, contents); } + var path = Path.Combine(destination, InstallManifest.FileName); + File.CreateSymbolicLink(path, target); + try + { + var error = Assert.Throws(() => + SkillInstallService.InstalledSkills(destination, temp.Path)); + + Assert.Contains("regular", error.Message); + if (dangling) { Assert.False(File.Exists(target)); } + else { Assert.Equal(contents, File.ReadAllText(target)); } + } + finally { File.Delete(path); } + } + [Fact] public void Install_copies_skills_from_packages_that_ship_them_and_ignores_the_rest() { diff --git a/dotnet-package-skills/tests/SkillInstallerTests.cs b/dotnet-package-skills/tests/SkillInstallerTests.cs index bab35a0..61dc9d9 100644 --- a/dotnet-package-skills/tests/SkillInstallerTests.cs +++ b/dotnet-package-skills/tests/SkillInstallerTests.cs @@ -829,6 +829,77 @@ public void A_corrupt_manifest_blocks_uninstall_and_preserves_everything() Assert.True(File.Exists(Path.Combine(destination, "mockly", "SKILL.md"))); } + [SymbolicLinkTheory] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public void Linked_manifests_stop_install_and_uninstall_before_skill_changes( + bool uninstall, bool dryRun, bool dangling) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.0.0", "usage")], dryRun: false); + var handwritten = temp.CreateFile("dest/handwritten/SKILL.md", "ours"); + var tracked = File.ReadAllBytes(Path.Combine(destination, "usage", "SKILL.md")); + var path = Path.Combine(destination, InstallManifest.FileName); + var original = File.ReadAllBytes(path); + var target = temp.Combine("outside.json"); + if (!dangling) { File.WriteAllBytes(target, original); } + File.Delete(path); + File.CreateSymbolicLink(path, target); + try + { + var error = Assert.Throws(() => + { + if (uninstall) { _installer.Uninstall(destination, null, null, dryRun); } + else { _installer.Install(destination, [Skill(temp, "Mockly", "2.0.0", "next")], dryRun); } + }); + + Assert.Contains("regular", error.Message); + Assert.Equal(tracked, File.ReadAllBytes(Path.Combine(destination, "usage", "SKILL.md"))); + Assert.Equal("ours", File.ReadAllText(handwritten)); + Assert.False(Directory.Exists(Path.Combine(destination, "next"))); + Assert.Equal(target, new FileInfo(path).LinkTarget); + if (dangling) { Assert.False(File.Exists(target)); } + else { Assert.Equal(original, File.ReadAllBytes(target)); } + } + finally { File.Delete(path); } + } + + [SymbolicLinkTheory] + [InlineData(false)] + [InlineData(true)] + public void A_linked_destination_directory_still_supports_regular_manifests(bool uninstall) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var alias = temp.Combine("alias"); + Directory.CreateSymbolicLink(alias, destination); + try + { + var skill = Skill(temp, "Mockly", "1.0.0", "usage"); + _installer.Install(alias, [skill], dryRun: false); + Assert.Single(InstallManifest.Load(alias).EnumerateSkills()); + if (uninstall) + { + temp.CreateFile("dest/handwritten/SKILL.md", "ours"); + Assert.Single(_installer.Uninstall(alias, null, null, dryRun: false)); + Assert.Equal("ours", File.ReadAllText(temp.Combine("dest", "handwritten", "SKILL.md"))); + } + else + { + _installer.Install(alias, [skill], dryRun: false); + Assert.Single(InstallManifest.Load(destination).EnumerateSkills()); + } + } + finally { Directory.Delete(alias); } + } + [Theory] [InlineData(false, false)] [InlineData(false, true)] @@ -928,6 +999,22 @@ public void Safe_names_with_dots_or_spaces_remain_installable_and_removable(stri Assert.Equal("ours", File.ReadAllText(handwritten)); } + [Theory] + [InlineData("1.10", "1.10.0+Build.99", true)] + [InlineData("01.02.003.0-BETA.1+Build.A", "1.2.3-beta.1+Build.B", true)] + [InlineData("1.2.3.4", "1.2.3.0", false)] + [InlineData("1.2.3-beta.1", "1.2.3-beta.2", false)] + [InlineData("1.2.3-beta", "1.2.3", false)] + public void Version_identity_drives_stale_checks_and_uninstall_filters( + string installed, string referenced, bool equivalent) + { + var entry = new TrackedSkill("Mockly", installed, "usage"); + + Assert.Equal(equivalent, SkillInstaller.SameVersion(installed, referenced)); + Assert.Equal(!equivalent, SkillInstaller.IsStale(entry, [new PackageReferenceInfo("mockly", referenced)])); + Assert.Equal(equivalent, SkillInstaller.Matches(entry, "MOCKLY", referenced)); + } + private static (string Path, string Contents)[] Snapshot(string root) => Directory.Exists(root) ? diff --git a/dotnet-package-skills/tests/TargetLocatorTests.cs b/dotnet-package-skills/tests/TargetLocatorTests.cs index 52db0b3..7d216b4 100644 --- a/dotnet-package-skills/tests/TargetLocatorTests.cs +++ b/dotnet-package-skills/tests/TargetLocatorTests.cs @@ -1,4 +1,6 @@ using DotnetPackageSkills.NuGet; +using System.Security.AccessControl; +using System.Security.Principal; namespace DotnetPackageSkills.Tests; @@ -115,4 +117,140 @@ public void Resolve_reports_a_missing_target_by_full_path() Assert.Contains("Ghost.sln", exception.Message); } + + [WindowsTheory] + [InlineData("bin")] + [InlineData("OBJ")] + [InlineData(".git")] + [InlineData("NODE_MODULES")] + [InlineData("artifacts")] + public void Ignored_subtrees_are_pruned_without_opening_them(string ignored) + { + using var temp = new TempDirectory(); + var blocked = temp.CreateDirectory("nested", ignored); + temp.CreateFile($"nested/{ignored}/Hidden.slnx"); + var expected = temp.CreateFile("src/Real.csproj"); + + WithBlockedEnumeration(blocked, () => Assert.Equal(expected, TargetLocator.Detect(temp.Path))); + } + + [WindowsTheory] + [InlineData(false)] + [InlineData(true)] + public void Inaccessible_children_do_not_hide_a_readable_sibling_but_explicit_roots_report_errors(bool explicitRoot) + { + using var temp = new TempDirectory(); + var blocked = temp.CreateDirectory("inaccessible"); + temp.CreateFile("inaccessible/Hidden.slnx"); + var expected = temp.CreateFile("src/Real.csproj"); + + WithBlockedEnumeration(blocked, () => + { + if (explicitRoot) + { + var error = Assert.Throws(() => TargetLocator.Resolve("inaccessible", temp.Path)); + Assert.Contains("read", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("inaccessible", error.Message); + } + else + { + Assert.Equal(expected, TargetLocator.Detect(temp.Path)); + } + }); + } + + [Theory] + [InlineData("bin")] + [InlineData("OBJ")] + [InlineData(".GIT")] + [InlineData("node_modules")] + [InlineData("ARTIFACTS")] + public void Every_ignored_name_is_excluded_at_any_depth(string ignored) + { + using var temp = new TempDirectory(); + temp.CreateFile($"nested/{ignored}/Hidden.slnx"); + var expected = temp.CreateFile("src/Real.vbproj"); + + Assert.Equal(expected, TargetLocator.Detect(temp.Path)); + Assert.EndsWith("Hidden.slnx", TargetLocator.Resolve(Path.Combine("nested", ignored), temp.Path)); + } + + [Theory] + [InlineData("src/A.sln", "src/Z.slnx", "src/Z.slnx")] + [InlineData("src/A.fsproj", "src/Z.csproj", "src/Z.csproj")] + [InlineData("src/A.vbproj", "src/Z.fsproj", "src/Z.fsproj")] + [InlineData("src/Z/A.sln", "src/A/Z.sln", "src/A/Z.sln")] + [InlineData("Root.csproj", "src/A.slnx", "Root.csproj")] + public void Target_ranking_keeps_extension_stage_and_ordinal_path_precedence( + string first, string second, string expected) + { + using var temp = new TempDirectory(); + temp.CreateFile(first); + temp.CreateFile(second); + + Assert.Equal(temp.Combine(expected.Split('/')), TargetLocator.Detect(temp.Path)); + } + + [SymbolicLinkTheory] + [InlineData(false)] + [InlineData(true)] + public void Automatic_search_skips_directory_links_but_explicit_targets_accept_them(bool explicitTarget) + { + using var temp = new TempDirectory(); + var root = temp.CreateDirectory("repo"); + var outside = temp.CreateDirectory("outside"); + temp.CreateFile("outside/App.slnx"); + var link = Path.Combine(root, "linked"); + Directory.CreateSymbolicLink(link, outside); + try + { + if (explicitTarget) { Assert.EndsWith("App.slnx", TargetLocator.Resolve("linked", root)); } + else { Assert.Throws(() => TargetLocator.Detect(root)); } + } + finally { Directory.Delete(link); } + } + + [SymbolicLinkTheory] + [InlineData(false)] + [InlineData(true)] + public void Automatic_directory_link_cycles_do_not_recurse(bool parent) + { + using var temp = new TempDirectory(); + var root = temp.CreateDirectory("repo"); + var child = temp.CreateDirectory("repo", "nested"); + var link = Path.Combine(child, "cycle"); + Directory.CreateSymbolicLink(link, parent ? root : child); + try + { + Assert.Throws(() => TargetLocator.Detect(root)); + } + finally { Directory.Delete(link); } + } + + private static void WithBlockedEnumeration(string path, Action check) + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("This fixture uses Windows directory access rules."); + } + + var directory = new DirectoryInfo(path); + var original = directory.GetAccessControl(AccessControlSections.Access) + .GetSecurityDescriptorSddlForm(AccessControlSections.Access); + var denied = directory.GetAccessControl(AccessControlSections.Access); + denied.AddAccessRule(new FileSystemAccessRule( + WindowsIdentity.GetCurrent().User!, FileSystemRights.ListDirectory, AccessControlType.Deny)); + directory.SetAccessControl(denied); + try + { + Assert.Throws(() => Directory.EnumerateFileSystemEntries(path).ToArray()); + check(); + } + finally + { + var restored = new DirectorySecurity(); + restored.SetSecurityDescriptorSddlForm(original, AccessControlSections.Access); + directory.SetAccessControl(restored); + } + } } diff --git a/dotnet-package-skills/tests/TempDirectory.cs b/dotnet-package-skills/tests/TempDirectory.cs index fa4373f..4d4452c 100644 --- a/dotnet-package-skills/tests/TempDirectory.cs +++ b/dotnet-package-skills/tests/TempDirectory.cs @@ -30,6 +30,24 @@ public string CreateFile(string relativePath, string content = "") return full; } + internal static string? SymbolicLinkSkipReason { get; } = CheckSymbolicLinkCapability(); + + private static string? CheckSymbolicLinkCapability() + { + using var temp = new TempDirectory(); + try + { + File.CreateSymbolicLink(temp.Combine("file-link"), temp.Combine("missing-file")); + Directory.CreateSymbolicLink(temp.Combine("directory-link"), temp.CreateDirectory("directory")); + return null; + } + catch (Exception error) when (error is UnauthorizedAccessException or PlatformNotSupportedException || + error is IOException && (error.HResult & 0xffff) == 1314) + { + return $"This host cannot create the real symbolic links required by this test: {error.Message}"; + } + } + /// Builds an extracted-package layout with a bundled skill, mirroring the NuGet cache. public string CreatePackageWithSkill(string packageId, string version, params string[] skillNames) { @@ -59,3 +77,16 @@ public void Dispose() } } } + +internal sealed class SymbolicLinkTheoryAttribute : TheoryAttribute +{ + public SymbolicLinkTheoryAttribute() => Skip = TempDirectory.SymbolicLinkSkipReason; +} + +internal sealed class WindowsTheoryAttribute : TheoryAttribute +{ + public WindowsTheoryAttribute() + { + if (!OperatingSystem.IsWindows()) { Skip = "This test exercises Windows directory access rules."; } + } +} diff --git a/dotnet-package-skills/tests/TerminalTextTests.cs b/dotnet-package-skills/tests/TerminalTextTests.cs index 11b5cde..22d1e17 100644 --- a/dotnet-package-skills/tests/TerminalTextTests.cs +++ b/dotnet-package-skills/tests/TerminalTextTests.cs @@ -187,6 +187,35 @@ public void Terminal_escape_strings_do_not_leak_their_payload(string input, stri Assert.Equal(expected, TerminalText.Sanitize(input)); } + [Theory] + [InlineData("before\u001b\u001b]52;c;SECRET\aafter", "beforeafter")] + [InlineData("before\u001b\u001b]52;c;SECRET\u001b\\after", "beforeafter")] + [InlineData("before\u001b\u001b\u001b]52;c;SECRET\aafter", "beforeafter")] + [InlineData("before\u001b\u001bPSECRET\u001b\\after", "beforeafter")] + [InlineData("before\u001b\u001b\u001bPSECRET\aafter", "beforeafter")] + [InlineData("before\u001b\u001b[31mvisible\u001b\u001b[0mafter", "beforevisibleafter")] + [InlineData("before\u001b\u001b\u001b[2Jafter", "beforeafter")] + [InlineData("before\u001b\u001b]52;c;SECRET", "before")] + [InlineData("before\u001b\u001bPSECRET", "before")] + [InlineData("before\u001b\u001b[123;", "before")] + [InlineData("before\u001b\u001b]52;c;SECRET\u001b", "before")] + [InlineData("before\u001b\u001b", "before")] + [InlineData("before\u001b\u001b\u001b", "before")] + public void Repeated_escape_introducers_do_not_expose_commands_or_payloads(string input, string expected) + { + Assert.Equal(expected, TerminalText.Sanitize(input)); + Assert.Equal(expected, TerminalText.Sanitize(input, multiline: true)); + } + + [Theory] + [InlineData(false, " before after ")] + [InlineData(true, " before\nafter ")] + public void Repeated_escapes_preserve_surrounding_text_and_line_mode(bool multiline, string expected) + { + Assert.Equal(expected, + TerminalText.Sanitize(" before\u001b\u001b]52;c;SECRET\a\r\nafter ", multiline, trim: false)); + } + [Fact] public void Authored_unicode_is_not_forced_to_ASCII() { diff --git a/eng/pipelines/dotnet-package-skills/stage.yml b/eng/pipelines/dotnet-package-skills/stage.yml index 3dd4bf2..9254e75 100644 --- a/eng/pipelines/dotnet-package-skills/stage.yml +++ b/eng/pipelines/dotnet-package-skills/stage.yml @@ -70,12 +70,13 @@ stages: $(_OfficialBuildArgs) /p:DotNetFinalVersionKind=$(_FinalVersionKind) /p:NETCORE_ENGINEERING_TELEMETRY=false displayName: Build, test, and package with Arcade - - task: CopyFiles@2 + - pwsh: | + $packages = @(Get-ChildItem -LiteralPath "$(Build.SourcesDirectory)\artifacts\packages\Release\Shipping" -Filter 'dotnet-package-skills.*.nupkg' -File -ErrorAction Stop) + if ($packages.Count -eq 0) { throw 'No dotnet-package-skills NuGet package was produced.' } + $destination = "$(Build.ArtifactStagingDirectory)\packages" + [IO.Directory]::CreateDirectory($destination) | Out-Null + $packages | Copy-Item -Destination $destination -ErrorAction Stop displayName: Stage NuGet packages - inputs: - SourceFolder: $(Build.SourcesDirectory)\artifacts\packages\Release\Shipping - Contents: 'dotnet-package-skills.*.nupkg' - TargetFolder: $(Build.ArtifactStagingDirectory)\packages - ${{ if eq(parameters.isOfficialBuild, false) }}: - task: PublishPipelineArtifact@1 inputs: From 29372775b33bd28939aafda55bd2420c29487d7c Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Thu, 8 Oct 2026 11:09:59 -0700 Subject: [PATCH 10/10] Replace generated uninstall commands with option guidance Remove repository-path command reconstruction and quoting plumbing from stale and package-conflict advice. Preserve the distinct removal options and sanitized report context, add focused application message regressions, and document the user-deferred non-transactional prerelease limitation without changing mutation behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet-package-skills/README.md | 24 ++-- dotnet-package-skills/src/Cli/OutputWriter.cs | 5 +- .../src/SkillInstallService.cs | 66 ++--------- .../src/Skills/SkillInstaller.cs | 29 ++--- .../tests/OutputWriterTests.cs | 34 ++++-- .../tests/SkillInstallServiceTests.cs | 110 +++++++++++------- .../tests/SkillInstallerTests.cs | 31 ++++- 7 files changed, 160 insertions(+), 139 deletions(-) diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md index ba3eebf..c781c63 100644 --- a/dotnet-package-skills/README.md +++ b/dotnet-package-skills/README.md @@ -103,11 +103,12 @@ When a package leaves the project, `install` keeps its skills and lists them: 2 installed skills belong to a package that the target no longer references: fabrikam.testing-fakes (fabrikam.testing 1.4.0) fabrikam.testing-fixtures (fabrikam.testing 1.4.0) -Run 'dotnet-package-skills uninstall --stale' to remove them. +Use uninstall with --stale to remove skills that no longer match the project. ``` -The suggested command repeats the `--target` and `--destination` that you passed, so you can run -it exactly as printed. Every command that the tool's errors suggest works the same way. +The tool gives removal-option guidance, not a paste-ready command with repository paths. +Use the reported Target and Destination context to choose the project and skills folder for +your uninstall. A package reference can disappear for a moment, for example halfway through a refactor. Because of this, removing skills is always a command that you run on purpose. `uninstall --stale` removes @@ -172,10 +173,9 @@ cases: - The target resolves a package to more than one version, or `--package` names a package more than once. - With a target, a package that the target resolves is missing from the NuGet cache. -- With a target, an installed skill is stale. Run `dotnet-package-skills uninstall --stale` - first. -- With `--package`, a named package is installed at a different version. Run - `dotnet-package-skills uninstall --package ` first. You can also run +- With a target, an installed skill is stale. Use uninstall with `--stale` first. +- With `--package`, a named package is installed at a different version. Use uninstall with + `--package` to remove that package's installed skills first. You can also run `install --package @` without `--interactive` to move the package to its new version. @@ -403,8 +403,9 @@ One combination stops `install` instead of skipping a file. This happens when th moves to a version that no longer ships the skill, while a different package ships a skill with that same name. Removing the old copy would hand the name to the other package. Keeping the old copy would record it under the new version's number, which would be wrong. For both reasons, -`install` changes nothing in this case. It suggests `uninstall --package ` for the owner. -After you run that command, `install` copies both packages' current skills. +`install` changes nothing in this case. It directs you to uninstall with `--package` for the +owner. After you remove the owner's installed skills, `install` copies both packages' current +skills. V1 does not reconcile two folders that differ only in physical case on a case-sensitive file system. Keep an authored skill folder's casing stable across versions. Avoid folders such as @@ -538,6 +539,11 @@ A destination directory reached through a link or junction remains supported. Th is limited to the manifest file entry; it does not guarantee safety against a link replaced concurrently between checking and writing. +Install and uninstall are not transactional in this prerelease. A failed skill copy or manifest +write (for example a read-only or locked manifest, or exhausted disk space), or an interruption, +can leave changed skills and a stale or partial manifest. Detected failures return a nonzero +exit; changes are not rolled back or recovered automatically. + The tool serializes concurrent operations on the same destination. It rejects an interactive choice if ownership changed before the tool could apply that choice. diff --git a/dotnet-package-skills/src/Cli/OutputWriter.cs b/dotnet-package-skills/src/Cli/OutputWriter.cs index 97ca208..45108ba 100644 --- a/dotnet-package-skills/src/Cli/OutputWriter.cs +++ b/dotnet-package-skills/src/Cli/OutputWriter.cs @@ -92,7 +92,7 @@ private void WriteContext(InstallResult result) /// /// Install never removes a skill because its package left the project, so say which ones - /// stayed and which command removes them. + /// stayed and which removal option applies. /// private void WriteUnreferenced(InstallResult result) { @@ -119,8 +119,7 @@ private void WriteUnreferenced(InstallResult result) output.WriteLine($" {Describe(entry.Skill, entry.Package, entry.Version)}"); } - output.WriteLine( - $"Run '{TerminalText.Sanitize(result.StaleCommand)}' to remove {(one ? "it" : "them")}."); + output.WriteLine(SkillInstallService.StaleSkillAdvice); } private void WriteSkipped(InstallResult result) diff --git a/dotnet-package-skills/src/SkillInstallService.cs b/dotnet-package-skills/src/SkillInstallService.cs index e1af1cd..1ff0288 100644 --- a/dotnet-package-skills/src/SkillInstallService.cs +++ b/dotnet-package-skills/src/SkillInstallService.cs @@ -46,16 +46,10 @@ public sealed record InstallResult /// /// Installed skills whose package the target no longer references. Install keeps them, and - /// the report points at uninstall --stale, the one command that removes them. + /// the report points at uninstall's --stale option for removing them. /// public IReadOnlyList Unreferenced { get; init; } = []; - /// - /// The command the report suggests for removing skills, spelled - /// with the target and destination this run used. - /// - public string StaleCommand { get; init; } = "dotnet-package-skills uninstall --stale"; - /// /// Set when an interactive install found skills but every one is installed already or /// skipped, so there was nothing to choose from and no checklist was shown. @@ -75,6 +69,9 @@ public sealed record SkillChoice(IReadOnlyList Selected) /// Ties package listing, skill discovery, and installation together. public sealed class SkillInstallService(DotnetCli dotnet, SkillInstaller installer) { + internal const string StaleSkillAdvice = + "Use uninstall with --stale to remove skills that no longer match the project."; + public SkillInstallService(IProcessRunner runner) : this(new DotnetCli(runner), new SkillInstaller()) { } @@ -222,8 +219,7 @@ public InstallResult Install(InstallRequest request, InstallResult discovered, S choice?.Selected ?? discovered.AllCandidates ?? discovered.Skills, request.DryRun, offered, - choice?.ExpectedInstalled, - arguments => UninstallCommand(request, arguments)); + choice?.ExpectedInstalled); return discovered with { @@ -236,7 +232,6 @@ public InstallResult Install(InstallRequest request, InstallResult discovered, S // A target lists every package it references, so anything it did not offer has left // the project. Named packages say nothing about the rest, so they report nothing. Unreferenced = request.Packages.Count == 0 && choice is null ? outcome.Untouched : [], - StaleCommand = UninstallCommand(request, "--stale", withTarget: true), AllCandidates = null, }; } @@ -326,11 +321,11 @@ internal InstallResult PrepareInteractiveInstall( if (request.Packages.Count == 0) { RequireEveryPackageInCache(discovered); - RequireNoStaleSkills(request, discovered, installed); + RequireNoStaleSkills(discovered, installed); } else { - RequireNoOtherInstalledVersion(request, discovered, installed); + RequireNoOtherInstalledVersion(discovered, installed); } var preview = Install( @@ -347,7 +342,6 @@ internal InstallResult PrepareInteractiveInstall( } private static void RequireNoStaleSkills( - InstallRequest request, InstallResult discovered, IReadOnlyCollection installed) { @@ -365,12 +359,11 @@ private static void RequireNoStaleSkills( $"Cannot choose skills interactively because {stale.Count} installed " + $"{(stale.Count == 1 ? "skill doesn't" : "skills don't")} match the target: " + $"{string.Join(", ", stale.Select(entry => $"{entry.Skill} ({entry.Package} {entry.Version})"))}. " + - $"Run '{UninstallCommand(request, "--stale", withTarget: true)}' first, and then try again. " + + StaleSkillAdvice + " Then try again. " + "No skills were changed."); } private static void RequireNoOtherInstalledVersion( - InstallRequest request, InstallResult discovered, IReadOnlyCollection installed) { @@ -388,13 +381,10 @@ private static void RequireNoOtherInstalledVersion( return; } - var command = conflicts.Count == 1 - ? $"'{UninstallCommand(request, $"--package {conflicts[0].Id}")}'" - : $"'{UninstallCommand(request, "--package ")}' for each of them"; throw new PackageSkillsException( $"{string.Join(" and ", conflicts.Select(conflict => $"{conflict.Id} {conflict.Installed!.Version}"))} " + $"{(conflicts.Count == 1 ? "is" : "are")} already installed, and an interactive install only adds " + - $"skills, so it can't change a package's version. Run {command} first, and then try again. " + + "skills, so it can't change a package's version. " + SkillInstaller.PackageConflictAdvice + " " + "No skills were changed."); } @@ -461,44 +451,6 @@ private static SkippedSkill ToSkipped(BundledSkill skill, string reason) => skill.PackageVersion, skill.SkillName, reason); - - /// - /// Spells an uninstall command that a report or an error suggests, with the target and the - /// destination this run used. - /// - /// Repeat --target, which only uninstall --stale accepts. - internal static string UninstallCommand(InstallRequest request, string arguments, bool withTarget = false) - { - // A suggestion is only useful if running it as printed acts on the same skills folder, - // compared against the same project. - var command = $"dotnet-package-skills uninstall {arguments}"; - - if (withTarget && request.Target is not null) - { - command += $" --target {CommandArgument(request.Target)}"; - } - - if (!IsDefaultDestination(request)) - { - command += $" --destination {CommandArgument(request.Destination)}"; - } - - return command; - } - - private static bool IsDefaultDestination(InstallRequest request) => - Path.TrimEndingDirectorySeparator(Path.GetFullPath(request.Destination, request.WorkingDirectory)).Equals( - Path.TrimEndingDirectorySeparator(Path.GetFullPath(InstallRequest.DefaultDestination, request.WorkingDirectory)), - OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - - /// - /// Quotes a value that a shell would otherwise split or reinterpret. Backslashes count: - /// bash treats them as escapes outside quotes, and every shell reads them literally inside. - /// - private static string CommandArgument(string value) => - value.Length > 0 && value.All(character => char.IsAsciiLetterOrDigit(character) || "._-/:+@".Contains(character)) - ? value - : $"\"{value}\""; } /// A solution or project and the package versions it references directly. diff --git a/dotnet-package-skills/src/Skills/SkillInstaller.cs b/dotnet-package-skills/src/Skills/SkillInstaller.cs index a1986f5..9035ffa 100644 --- a/dotnet-package-skills/src/Skills/SkillInstaller.cs +++ b/dotnet-package-skills/src/Skills/SkillInstaller.cs @@ -16,6 +16,9 @@ public sealed record InstallOutcome( /// Copies discovered skills into the destination and keeps the manifest in step. public sealed class SkillInstaller { + internal const string PackageConflictAdvice = + "Use uninstall with --package to remove skills for the conflicting packages, then try again."; + /// /// Copies every skill into , refreshing the ones this tool /// already installed. @@ -29,17 +32,12 @@ public sealed class SkillInstaller /// that cleanup belongs to uninstall --stale. Pass an empty map to only add skills. /// Null offers the packages of at their versions. /// - /// - /// Spells the uninstall command that an error suggests, given its arguments, so it can name - /// the destination the caller was given. - /// public InstallOutcome Install( string destinationRoot, IReadOnlyList skills, bool dryRun, IReadOnlyDictionary? offered = null, - IReadOnlyCollection? expectedInstalled = null, - Func? uninstallCommand = null) + IReadOnlyCollection? expectedInstalled = null) { // Package ids compare without regard to case, whatever comparer the caller's map uses: // the manifest spells them in lowercase, and packages keep NuGet's casing. @@ -90,7 +88,8 @@ public InstallOutcome Install( skipped.Add(ToSkipped( skill, $"the destination folder is managed for {tracked.Package} {tracked.Version} " + - $"skill '{tracked.Skill}'; uninstall that skill before replacing its owner")); + $"skill '{tracked.Skill}'; use uninstall with --package to remove the current owner's skills " + + "before replacing it")); protectedPaths.Add(tracked.Skill); continue; } @@ -111,11 +110,7 @@ public InstallOutcome Install( if (stranded.Count > 0) { - throw NameWouldChangeOwner( - stranded, - versions, - selected, - uninstallCommand ?? (arguments => $"dotnet-package-skills uninstall {arguments}")); + throw NameWouldChangeOwner(stranded, versions, selected); } var current = accepted.Select(skill => skill.RelativePath).ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -195,8 +190,7 @@ internal static bool SameVersion(string left, string right) => private static PackageSkillsException NameWouldChangeOwner( IReadOnlyList stranded, IReadOnlyDictionary versions, - IReadOnlyList selected, - Func uninstallCommand) + IReadOnlyList selected) { // The offered map keeps NuGet's casing, which reads better than the manifest's. string OwnerId(TrackedSkill entry) => @@ -210,15 +204,10 @@ string OwnerId(TrackedSkill entry) => $"and {(other is null ? "another package" : $"{other.PackageId} {other.PackageVersion}")} " + "ships a skill with that name"; }); - var owners = stranded.Select(OwnerId).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - var command = owners.Count == 1 - ? $"'{uninstallCommand($"--package {owners[0]}")}'" - : $"'{uninstallCommand("--package ")}' for each of {string.Join(", ", owners)}"; - return new PackageSkillsException( $"Cannot install skills because {string.Join("; ", reasons)}. The tool doesn't hand an installed " + "skill to another package, and the manifest records one version per package, so it can't keep the " + - $"older copy either. Run {command} first, and then try again. No skills were changed."); + "older copy either. " + PackageConflictAdvice + " No skills were changed."); } /// diff --git a/dotnet-package-skills/tests/OutputWriterTests.cs b/dotnet-package-skills/tests/OutputWriterTests.cs index 2aff491..5a50c4a 100644 --- a/dotnet-package-skills/tests/OutputWriterTests.cs +++ b/dotnet-package-skills/tests/OutputWriterTests.cs @@ -64,7 +64,7 @@ public void Install_report_warns_about_skipped_collisions() } [Fact] - public void Skills_whose_package_left_the_target_are_listed_with_the_command_that_removes_them() + public void Skills_whose_package_left_the_target_are_listed_with_stale_option_guidance() { using var output = new StringWriter(); var result = ResultWithCollision() with @@ -77,7 +77,7 @@ public void Skills_whose_package_left_the_target_are_listed_with_the_command_tha Assert.Contains( "1 installed skill belongs to a package that the target no longer references:" + Environment.NewLine + " contoso.widgets-usage (contoso.widgets 2.3.0)" + Environment.NewLine + - "Run 'dotnet-package-skills uninstall --stale' to remove it.", + "Use uninstall with --stale to remove skills that no longer match the project.", output.ToString()); } @@ -99,25 +99,39 @@ public void The_stale_hint_counts_skills_and_packages_separately(string secondPa new OutputWriter(output).WriteInstallReport(result, copied: true); Assert.Contains($"2 installed skills belong to {packages} that the target no longer references:", output.ToString()); - Assert.Contains("Run 'dotnet-package-skills uninstall --stale' to remove them.", output.ToString()); + Assert.Contains("Use uninstall with --stale to remove skills that no longer match the project.", output.ToString()); } - [Fact] - public void The_stale_hint_prints_the_command_for_the_destination_and_target_that_were_used() + [Theory] + [InlineData("my skills")] + [InlineData("src/$(command).sln")] + [InlineData("$(Write-Host injected)")] + [InlineData("my`skills")] + [InlineData("my\"skills")] + [InlineData("path;command")] + [InlineData("path\ncommand")] + [InlineData("path\tcommand")] + [InlineData("my\u001b[2Jskills")] + public void Stale_advice_never_formats_report_paths_as_an_executable_command(string path) { using var output = new StringWriter(); var result = ResultWithCollision() with { Unreferenced = [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")], - StaleCommand = "dotnet-package-skills uninstall --stale --destination \"my\u001b[2Jskills\"", + Target = path, + Destination = path, }; new OutputWriter(output).WriteInstallReport(result, copied: true); - Assert.Contains( - "Run 'dotnet-package-skills uninstall --stale --destination \"myskills\"' to remove it.", - output.ToString()); - Assert.DoesNotContain('\u001b', output.ToString()); + var report = output.ToString(); + Assert.Contains($"Target: {TerminalText.Sanitize(path)}", report); + Assert.Contains($"Destination: {TerminalText.Sanitize(path)}", report); + Assert.Contains("Use uninstall with --stale to remove skills that no longer match the project.", report); + Assert.DoesNotContain("dotnet-package-skills uninstall", report); + Assert.DoesNotContain("--target", report); + Assert.DoesNotContain("--destination", report); + AssertPlainText(report); } [Fact] diff --git a/dotnet-package-skills/tests/SkillInstallServiceTests.cs b/dotnet-package-skills/tests/SkillInstallServiceTests.cs index b68b963..8abdda1 100644 --- a/dotnet-package-skills/tests/SkillInstallServiceTests.cs +++ b/dotnet-package-skills/tests/SkillInstallServiceTests.cs @@ -1,3 +1,4 @@ +using DotnetPackageSkills.Cli; using DotnetPackageSkills.Infrastructure; using DotnetPackageSkills.NuGet; using DotnetPackageSkills.Skills; @@ -291,7 +292,7 @@ public void An_interactive_target_install_stops_while_installed_skills_are_stale ? "1 installed skill doesn't match the target: mockly (mockly 1.10.0)" : "1 installed skill doesn't match the target: widget-usage (contoso.widgets 2.3.0)", error.Message); - Assert.Contains("uninstall --stale", error.Message); + Assert.Contains("Use uninstall with --stale", error.Message); Assert.Contains("No skills were changed", error.Message); Assert.Equal(before, Snapshot(destination)); } @@ -349,45 +350,54 @@ public void An_interactive_install_of_a_named_package_stops_when_another_version request, service.Discover(request), SkillInstallService.InstalledSkills(destination, temp.Path))); Assert.Contains("Mockly 1.10.0 is already installed", error.Message); - Assert.Contains("'dotnet-package-skills uninstall --package Mockly'", error.Message); + Assert.Contains("Use uninstall with --package", error.Message); + Assert.DoesNotContain("--stale", error.Message); Assert.Equal(before, Snapshot(destination)); } [Theory] - [InlineData(".agents/skills", null, "--stale", true, "dotnet-package-skills uninstall --stale")] - [InlineData(".agents/skills/", null, "--stale", true, "dotnet-package-skills uninstall --stale")] - [InlineData(".claude/skills", null, "--stale", true, "dotnet-package-skills uninstall --stale --destination .claude/skills")] - [InlineData( - "my skills", "src/My App.slnx", "--stale", true, - "dotnet-package-skills uninstall --stale --target \"src/My App.slnx\" --destination \"my skills\"")] - [InlineData( - ".claude/skills", "src/App.slnx", "--package Mockly", false, - "dotnet-package-skills uninstall --package Mockly --destination .claude/skills")] - [InlineData( - @"C:\src\skills", null, "--stale", true, - "dotnet-package-skills uninstall --stale --destination \"C:\\src\\skills\"")] - public void Suggested_commands_repeat_the_target_and_destination_that_were_used( - string destination, string? target, string arguments, bool withTarget, string expected) + [InlineData("my skills")] + [InlineData("src/$(command).sln")] + [InlineData("$(Write-Host injected)")] + [InlineData("my`skills")] + [InlineData("my\"skills")] + [InlineData("path;command")] + [InlineData("path\ncommand")] + [InlineData("path\tcommand")] + [InlineData("my\u001b[2Jskills")] + public void Interactive_option_guidance_does_not_interpolate_arbitrary_request_paths(string path) { - // A suggestion is only useful if running it as printed acts on the same skills folder, - // compared against the same project. using var temp = new TempDirectory(); - var request = Request(temp) with { Destination = destination, Target = target }; - - Assert.Equal(expected, SkillInstallService.UninstallCommand(request, arguments, withTarget)); - } - - [Fact] - public void A_suggested_command_leaves_out_a_destination_that_is_the_default_spelled_in_full() - { - using var temp = new TempDirectory(); - var request = Request(temp) with { Destination = temp.Combine(".agents", "skills") }; + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var packages = new[] { PackageCoordinate.Parse("Mockly@1.10.0") }; + var discovered = service.Discover(Request(temp) with { Packages = packages }); + TrackedSkill[] installed = [new("mockly", "1.9.0", "mockly")]; - Assert.Equal("dotnet-package-skills uninstall --stale", SkillInstallService.UninstallCommand(request, "--stale")); + foreach (var packageFilter in new[] { false, true }) + { + var request = Request(temp) with + { + Destination = path, + Target = path, + Packages = packageFilter ? packages : [], + }; + var error = Assert.Throws(() => + service.PrepareInteractiveInstall(request, discovered, installed)); + + Assert.Contains(packageFilter + ? "Use uninstall with --package to remove skills for the conflicting packages, then try again." + : "Use uninstall with --stale to remove skills that no longer match the project.", error.Message); + Assert.DoesNotContain(packageFilter ? "--stale" : "--package", error.Message); + Assert.DoesNotContain(path, error.Message); + Assert.DoesNotContain("dotnet-package-skills uninstall", error.Message); + Assert.DoesNotContain("--target", error.Message); + Assert.DoesNotContain("--destination", error.Message); + } } [Fact] - public void The_stale_hint_and_the_stale_stop_name_the_destination_that_was_used() + public void Stale_guidance_is_separate_from_nondefault_destination_context() { using var temp = new TempDirectory(); temp.CreateFile("MyApp.sln"); @@ -402,13 +412,18 @@ public void The_stale_hint_and_the_stale_stop_name_the_destination_that_was_used var error = Assert.Throws(() => service.PrepareInteractiveInstall( request, service.Discover(request), SkillInstallService.InstalledSkills(".claude/skills", temp.Path))); - const string Command = "dotnet-package-skills uninstall --stale --destination .claude/skills"; - Assert.Equal(Command, result.StaleCommand); - Assert.Contains($"Run '{Command}' first", error.Message); + using var output = new StringWriter(); + new OutputWriter(output).WriteInstallReport(result, copied: true); + + Assert.Contains($"Destination: {result.Destination}", output.ToString()); + Assert.Contains("Use uninstall with --stale to remove skills that no longer match the project.", output.ToString()); + Assert.Contains("Use uninstall with --stale to remove skills that no longer match the project.", error.Message); + Assert.DoesNotContain("--destination", output.ToString()); + Assert.DoesNotContain("--destination", error.Message); } [Fact] - public void The_other_version_stop_names_the_destination_that_was_used() + public void An_installed_other_version_requires_package_option_guidance_not_stale_removal() { using var temp = new TempDirectory(); temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); @@ -421,11 +436,21 @@ public void The_other_version_stop_names_the_destination_that_was_used() var error = Assert.Throws(() => service.PrepareInteractiveInstall( upgrade, service.Discover(upgrade), SkillInstallService.InstalledSkills(".claude/skills", temp.Path))); - Assert.Contains("'dotnet-package-skills uninstall --package Mockly --destination .claude/skills' first", error.Message); + Assert.Contains("Use uninstall with --package to remove skills for the conflicting packages, then try again.", error.Message); + Assert.DoesNotContain("--stale", error.Message); + Assert.DoesNotContain("--destination", error.Message); } - [Fact] - public void A_version_change_that_would_hand_a_skill_to_another_package_stops_with_a_command_for_this_destination() + [Theory] + [InlineData("my skills")] + [InlineData("src/$(command).sln")] + [InlineData("$(Write-Host injected)")] + [InlineData("my`skills")] + [InlineData("my\"skills")] + [InlineData("path;command")] + [InlineData("path\ncommand")] + [InlineData("my\u001b[2Jskills")] + public void Name_owner_conflicts_use_package_option_prose_without_request_path_commands(string path) { using var temp = new TempDirectory(); temp.CreateFile("MyApp.sln"); @@ -439,10 +464,17 @@ public void A_version_change_that_would_hand_a_skill_to_another_package_stops_wi var service = new SkillInstallService(new FakeDotnet( temp.Combine("packages"), Json(("Alpha", "2.0.0"), ("Beta", "1.0.0")))); - var error = Assert.Throws(() => service.Install(request)); + var discovered = service.Discover(request); + var error = Assert.Throws(() => + service.Install(request with { Target = path, Destination = path }, discovered, choice: null)); Assert.Contains("Alpha 2.0.0 no longer ships the installed skill 'shared'", error.Message); - Assert.Contains("'dotnet-package-skills uninstall --package Alpha --destination .claude/skills' first", error.Message); + Assert.Contains("Use uninstall with --package to remove skills for the conflicting packages, then try again.", error.Message); + Assert.DoesNotContain("--stale", error.Message); + Assert.DoesNotContain(path, error.Message); + Assert.DoesNotContain("dotnet-package-skills uninstall", error.Message); + Assert.DoesNotContain("--target", error.Message); + Assert.DoesNotContain("--destination", error.Message); Assert.Equal(before, Snapshot(destination)); } diff --git a/dotnet-package-skills/tests/SkillInstallerTests.cs b/dotnet-package-skills/tests/SkillInstallerTests.cs index 61dc9d9..bdad81b 100644 --- a/dotnet-package-skills/tests/SkillInstallerTests.cs +++ b/dotnet-package-skills/tests/SkillInstallerTests.cs @@ -362,6 +362,8 @@ public void Complete_install_preserves_a_path_owned_by_another_package(bool dryR Assert.Empty(outcome.Installed); Assert.Empty(outcome.Removed); Assert.Contains("managed for contoso.widgets", Assert.Single(outcome.Skipped).Reason); + Assert.Contains("use uninstall with --package", Assert.Single(outcome.Skipped).Reason); + Assert.DoesNotContain("--stale", Assert.Single(outcome.Skipped).Reason); Assert.Equal("contoso.widgets", Assert.Single(InstallManifest.Load(destination).Packages).Key); Assert.Equal(contents, File.ReadAllBytes(Path.Combine(destination, "shared-skill", "SKILL.md"))); Assert.Equal(manifest, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); @@ -393,11 +395,38 @@ public void Moving_a_package_to_a_version_without_a_skill_that_another_package_s Assert.Contains( "Alpha 2.0.0 no longer ships the installed skill 'shared', and Beta 2.0.0 ships a skill with that name", error.Message); - Assert.Contains("'dotnet-package-skills uninstall --package Alpha' first", error.Message); + Assert.Contains("Use uninstall with --package to remove skills for the conflicting packages, then try again.", error.Message); + Assert.DoesNotContain("dotnet-package-skills uninstall", error.Message); + Assert.DoesNotContain("--stale", error.Message); Assert.Contains("No skills were changed", error.Message); Assert.Equal(before, Snapshot(destination)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Multiple_owner_conflicts_require_package_option_guidance_without_generated_commands(bool dryRun) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, + [Skill(temp, "Alpha", "1.0.0", "alpha-shared"), Skill(temp, "Gamma", "1.0.0", "gamma-shared")], + dryRun: false); + var before = Snapshot(destination); + + var error = Assert.Throws(() => + _installer.Install(destination, + [Skill(temp, "Beta", "2.0.0", "alpha-shared"), Skill(temp, "Beta", "2.0.0", "gamma-shared")], + dryRun, offered: Offer(("Alpha", "2.0.0"), ("Gamma", "2.0.0"), ("Beta", "2.0.0")))); + + Assert.Contains("Alpha", error.Message); + Assert.Contains("Gamma", error.Message); + Assert.Contains("Use uninstall with --package to remove skills for the conflicting packages, then try again.", error.Message); + Assert.DoesNotContain("dotnet-package-skills uninstall", error.Message); + Assert.DoesNotContain("--stale", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + [Fact] public void A_conflicting_copy_keeps_its_owner_when_the_owners_package_is_not_part_of_the_run() {