diff --git a/.gitignore b/.gitignore index 063b958..88590d6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ x86/ publish/ dist/ dist-admin/ +dist-author-cli/ +dist-author-cli-admin/ ## User-specific *.suo diff --git a/AccessibilityModManager.slnx b/AccessibilityModManager.slnx index f2cb105..bc5f867 100644 --- a/AccessibilityModManager.slnx +++ b/AccessibilityModManager.slnx @@ -1,6 +1,8 @@ + + diff --git a/README.md b/README.md index c96ebf0..f9efa8f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,95 @@ What the tool gives you: - **Upload releases to your mod's own GitHub repo** — the tool uses `gh` to create a GitHub release on your mod's repo and attach the wrapped ZIP as an asset, then writes the resulting public URL + SHA256 back into your plugin index. This is intentional: your mod stays on its own repo (where your users already look for it), and your plugin index simply points at those release assets. The plugin index repo itself is *not* released — it's just a regular `git commit` + `git push` of the updated `index.json`. One click does the asset upload, the index commit, and the index push together, so the SHA256 in your plugin index always matches the asset that's live on GitHub. - **Lifecycle script editor** — fill in the executable path, the what / why / modifies descriptions, and whether the script needs admin. The tool validates that each declared script is actually bundled in your source folder before producing the ZIP. +## Author CLI (local tooling) + +This branch also contains `amm-author`, a command-line counterpart to the WPF AuthorTool. It uses the same authoring services and validation rules, but it does not launch the graphical app. The CLI is local tooling in this branch, not an official binary published by the upstream project. + +Build self-contained Windows x64 executables with: + +```powershell +powershell -ExecutionPolicy Bypass -File installer\build-author-cli.ps1 -SelfContained +powershell -ExecutionPolicy Bypass -File installer\build-author-cli.ps1 -SelfContained -Admin +``` + +The standard executable is written to `dist-author-cli\amm-author.exe`. The registry-admin build is written separately to `dist-author-cli-admin\amm-author-admin.exe`. Each folder also receives a `.sha256` file. The build script does not create a GitHub release or upload anything. + +Copy the executables and hash files to a folder on your user `PATH`, then run `amm-author --help` or `amm-author-admin --help`. A self-contained build does not require the .NET Desktop Runtime on the destination machine. + +### Projects and output + +Commands that need an author project resolve it in this order: + +1. The folder supplied with `--project`. +2. The current directory, if it contains an `index.json` project. +3. The last project opened by the AuthorTool or `project open`. + +The global options can appear before or after a subcommand: + +- `--json` writes machine-readable JSON. +- `--quiet` suppresses ordinary human status lines, but not warnings or errors. +- `--dry-run` validates and previews without making durable changes. +- `--yes` confirms an operation after validation; it does not bypass trust or safety checks. +- `--verbose` includes exception details when a command fails. + +Human output is plain text with no ANSI control sequences, so it remains predictable in screen readers and redirected logs. JSON mode keeps standard output parseable for scripts. + +Passphrases are never accepted as ordinary command-line values. Interactive prompts conceal them. For automation, redirect standard input and use the command's explicit `--passphrase-stdin` or `--passphrases-stdin` option. Do not put a secret in a JSON input file, shell history, or process argument. + +The process exit codes are `0` for success, `2` for command usage, `3` for validation failure, `4` for authentication or an unavailable privileged operation, `5` for a conflict or missing confirmation, and `130` for cancellation. + +### Command groups + +- `project` creates, opens, clones, pulls, and inspects author projects. +- `author` reads or changes the author block in `index.json`. +- `game` reads or changes game entries. +- `dependency` reads or changes a game's dependencies. +- `script` reads or changes default lifecycle scripts. +- `package` builds, validates, and hashes wrapped mod packages. +- `release` reads, edits, uploads, and completes release publication. +- `index` inspects, reconciles, saves, publishes, and manages index locks. +- `github` checks GitHub CLI authentication and lists repositories or releases. +- `patreon` manages the local Patreon session and reads creator posts or tiers. +- `server` configures and operates the SFTP publishing destination. +- `signing` manages catalog signing keys, claims, and publisher-head recovery. +- `registry` maintains the signed global registry. Every registry operation requires `amm-author-admin`. + +Use `--help` at any level for the exact arguments and a concrete example, such as `amm-author release publish --help`. + +### Examples + +Inspect a project as JSON: + +```powershell +amm-author project status --project "C:\Mods\Sample" --json --quiet +``` + +Build a wrapped package: + +```powershell +amm-author package build --source "C:\Mods\Sample\Files" --game sample-game --version 1.0.0 --output "C:\Packages\sample.zip" --project "C:\Mods\Sample" +``` + +Validate that package without changing the project: + +```powershell +amm-author package validate --file "C:\Packages\sample.zip" --json +``` + +Preview an index publication without committing or pushing: + +```powershell +amm-author index publish --project "C:\Mods\Sample" --dry-run +``` + +Publish a release after reviewing its destination: + +```powershell +amm-author release publish --game sample-game --version 1.0.0 --channel stable --repo owner/sample-mod --zip "C:\Packages\sample.zip" --project "C:\Mods\Sample" --yes +``` + +The repository is source-available under [LICENSE](LICENSE). Building these programs for local use does not grant redistribution rights beyond that license. + ## Releasing a new version 1. Open the AuthorTool, open your plugin project (the folder with `index.json`). @@ -67,6 +156,7 @@ dotnet build AccessibilityModManager.slnx dotnet test AccessibilityModManager.slnx powershell -ExecutionPolicy Bypass -File installer\build.ps1 # manager + Inno installer powershell -ExecutionPolicy Bypass -File installer\build-author-tool.ps1 # AuthorTool single-file exe +powershell -ExecutionPolicy Bypass -File installer\build-author-cli.ps1 -SelfContained # local Author CLI ``` Targets `net10.0-windows`. Requires .NET 10 SDK and (for the installer) [Inno Setup 6](https://jrsoftware.org/isdl.php). diff --git a/docs/superpowers/plans/2026-08-04-full-author-cli.md b/docs/superpowers/plans/2026-08-04-full-author-cli.md new file mode 100644 index 0000000..d15523a --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-full-author-cli.md @@ -0,0 +1,1075 @@ +# Full Accessibility Mod Manager Author CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a local `amm-author.exe` with feature parity with Accessibility Mod Manager AuthorTool 0.28.0 while preserving the exact package, catalog, trust, signing, and publishing safeguards. + +**Architecture:** Move the AuthorTool's UI-independent services unchanged into a shared `AccessibilityModManager.Authoring` assembly, then add typed workflows that both the WPF application and a new `AccessibilityModManager.AuthorCli` console application can call. The CLI uses `System.CommandLine` 2.0.10, screen-reader-safe line output, JSON payloads for lossless complex model editing, existing AuthorTool configuration, and the existing security services. + +**Tech Stack:** C# 14, .NET 10 Windows, WPF, System.CommandLine 2.0.10, Microsoft.Extensions.DependencyInjection 10.0.3, Serilog 4.3.1, SSH.NET 2024.1.0, xUnit 2.9.3, Git and GitHub CLI. + +## Global Constraints + +- Start from upstream commit `9e2d223762aa21a2fc765bae55380699a2532746`, AuthorTool version 0.28.0. +- Keep the work local on branch `local/full-author-cli`; do not fork, push, open a pull request, create a release, or redistribute a binary. +- Do not weaken, bypass, replace, or add override switches for any package, path, manifest, HTTPS, identity, registry, signing, replay, host-key, or publish-lock check. +- Preserve `%LocalAppData%\AccessibilityModManager-Author\config.json` compatibility and DPAPI protection. +- Target `net10.0-windows` and publish Windows x64 executables. +- Standard builds exclude registry-admin execution; admin builds enable it only through the existing `RegistryAdmin=true` build property. +- Default output is complete plain-text lines without animation, cursor rewriting, color-only meaning, decorative tables, or unlabeled symbols. +- Passphrases are read through concealed input or `--passphrase-stdin`; they are never accepted as ordinary option values or logged. +- `--yes` confirms an action but never bypasses validation or trust gates. +- `--dry-run` performs reads and validation but never writes, commits, uploads, signs, changes gates, or removes locks. +- Use test-first development for every new behavior and run `dotnet test AccessibilityModManager.slnx` before each task commit. + +## File Structure + +### Shared authoring assembly + +- Create `src/AccessibilityModManager.Authoring/AccessibilityModManager.Authoring.csproj`. +- Move the 22 UI-independent files from `src/AccessibilityModManager.AuthorTool/Services/` to `src/AccessibilityModManager.Authoring/Services/` without changing their namespaces or behavior. +- Create `src/AccessibilityModManager.Authoring/Workflows/WorkflowResult.cs` for stable operation categories and typed results. +- Create `src/AccessibilityModManager.Authoring/Workflows/AuthorProjectContext.cs` for project resolution and locking. +- Create `src/AccessibilityModManager.Authoring/Workflows/JsonPayloadService.cs` for exact model import and export. +- Create `src/AccessibilityModManager.Authoring/Workflows/CatalogWorkflow.cs` for author, game, dependency, script, and release mutations. +- Create `src/AccessibilityModManager.Authoring/Workflows/PackageWorkflow.cs` for build and package validation. +- Create `src/AccessibilityModManager.Authoring/Workflows/ReleaseWorkflow.cs` for staged-byte validation and GitHub/server release publication. +- Create `src/AccessibilityModManager.Authoring/Workflows/IndexWorkflow.cs` for reconciliation, validation, saving, and destination publication. +- Create `src/AccessibilityModManager.Authoring/Workflows/PatreonWorkflow.cs` and `ServerWorkflow.cs`. +- Create `src/AccessibilityModManager.Authoring/Workflows/SigningWorkflow.cs` and `RegistryAdminWorkflow.cs`. + +### CLI assembly + +- Create `src/AccessibilityModManager.AuthorCli/AccessibilityModManager.AuthorCli.csproj`. +- Create `src/AccessibilityModManager.AuthorCli/Program.cs` and `CliServices.cs`. +- Create `src/AccessibilityModManager.AuthorCli/Console/CliConsole.cs`, `SecretReader.cs`, `OutcomeWriter.cs`, and `ExitCodes.cs`. +- Create one focused command-registration file for each top-level command under `src/AccessibilityModManager.AuthorCli/Commands/`. +- Create `src/AccessibilityModManager.AuthorCli/Properties/PublishProfiles/win-x64.pubxml`. + +### Existing WPF application + +- Modify `src/AccessibilityModManager.AuthorTool/AccessibilityModManager.AuthorTool.csproj` to reference Authoring. +- Modify `src/AccessibilityModManager.AuthorTool/App.xaml.cs` to register shared workflows. +- Modify author view models only where orchestration moves into a workflow; keep bindings, announcements, and dialog behavior unchanged. + +### Tests and delivery + +- Add `tests/AccessibilityModManager.Tests/Authoring/` workflow tests. +- Add `tests/AccessibilityModManager.Tests/AuthorCli/` parser, handler, output, and parity tests. +- Add `installer/build-author-cli.ps1`. +- Update `README.md` with local build and command documentation without linking to an unofficial binary. + +--- + +### Task 1: Extract the UI-independent Authoring Assembly + +**Files:** +- Create: `src/AccessibilityModManager.Authoring/AccessibilityModManager.Authoring.csproj` +- Move: `src/AccessibilityModManager.AuthorTool/Services/*.cs` to `src/AccessibilityModManager.Authoring/Services/*.cs` +- Modify: `src/AccessibilityModManager.AuthorTool/AccessibilityModManager.AuthorTool.csproj` +- Modify: `tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj` +- Modify: `AccessibilityModManager.slnx` +- Test: `tests/AccessibilityModManager.Tests/Authoring/AuthoringAssemblyTests.cs` + +**Interfaces:** +- Consumes: existing service types in namespace `AccessibilityModManager.AuthorTool.Services`. +- Produces: the same public service types from assembly `AccessibilityModManager.Authoring`, with unchanged names and signatures. + +- [ ] **Step 1: Record the clean baseline** + +Run: + +```powershell +dotnet test AccessibilityModManager.slnx --no-restore +``` + +Expected: all existing tests pass at commit `9e2d223` plus the design commit. + +- [ ] **Step 2: Write the failing assembly-boundary test** + +Create: + +```csharp +using AccessibilityModManager.AuthorTool.Services; + +namespace AccessibilityModManager.Tests.Authoring; + +public sealed class AuthoringAssemblyTests +{ + [Fact] + public void UiIndependentServicesLiveInAuthoringAssembly() + { + Assert.Equal("AccessibilityModManager.Authoring", + typeof(ManifestBuilderService).Assembly.GetName().Name); + Assert.Equal(typeof(ManifestBuilderService).Assembly, + typeof(IndexPublishCoordinator).Assembly); + Assert.Equal(typeof(ManifestBuilderService).Assembly, + typeof(ClaimSigningKeyStore).Assembly); + } +} +``` + +- [ ] **Step 3: Run the boundary test and verify failure** + +Run: + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter FullyQualifiedName~AuthoringAssemblyTests +``` + +Expected: failure because each service still lives in `AccessibilityModManager.AuthorTool`. + +- [ ] **Step 4: Create the shared project and move services without logic edits** + +Use this project definition: + +```xml + + + + + + + + + + + net10.0-windows + enable + enable + AccessibilityModManager.Authoring + + +``` + +Move every service file with its contents and `AccessibilityModManager.AuthorTool.Services` namespace unchanged. Add an Authoring project reference to the WPF and test projects, remove the WPF project's now-unused SSH.NET reference, and add the new project under `/src/` in `AccessibilityModManager.slnx`. + +- [ ] **Step 5: Run boundary and regression tests** + +Run: + +```powershell +dotnet test AccessibilityModManager.slnx +``` + +Expected: the new boundary test and every existing test pass. + +- [ ] **Step 6: Commit the pure extraction** + +```powershell +git add AccessibilityModManager.slnx src tests +git commit -m "refactor: share AuthorTool services" +``` + +--- + +### Task 2: Add CLI Results, Console I/O, Project Resolution, and Locking + +**Files:** +- Create: `src/AccessibilityModManager.Authoring/Workflows/WorkflowResult.cs` +- Create: `src/AccessibilityModManager.Authoring/Workflows/AuthorProjectContext.cs` +- Create: `src/AccessibilityModManager.Authoring/Workflows/JsonPayloadService.cs` +- Create: `src/AccessibilityModManager.AuthorCli/AccessibilityModManager.AuthorCli.csproj` +- Create: `src/AccessibilityModManager.AuthorCli/Program.cs` +- Create: `src/AccessibilityModManager.AuthorCli/CliServices.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Console/CliConsole.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Console/SecretReader.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Console/OutcomeWriter.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Console/ExitCodes.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/RootCommands.cs` +- Modify: `AccessibilityModManager.slnx` +- Modify: `tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/ProjectResolutionTests.cs` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/OutcomeWriterTests.cs` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/SecretReaderTests.cs` + +**Interfaces:** +- Produces: `WorkflowResult`, `WorkflowErrorKind`, `AuthorProjectContext.ResolveAsync`, `JsonPayloadService.ReadAsync`, `ICliConsole`, `CliExitCode`, and a runnable `amm-author --version`. +- Consumes: `AuthorConfigService`, `IndexFileService`, and `CrossProcessFileLock.AcquireAsync`. + +- [ ] **Step 1: Write project-resolution tests** + +Cover explicit path, current directory, last-opened project, missing project, and a project whose `index.json` is absent. The core assertion is: + +```csharp +var resolved = await context.ResolveAsync(explicitPath, currentDirectory, CancellationToken.None); +Assert.Equal(Path.GetFullPath(explicitPath), resolved.ProjectPath); +Assert.Equal("sample", resolved.Index.PluginId); +``` + +- [ ] **Step 2: Write output and secret-input tests** + +Verify human output uses complete lines, JSON output is a single valid object, errors do not enter standard output in JSON mode, and concealed input handles backspace without echoing characters: + +```csharp +var result = new WorkflowResult("ok", "value", []); +writer.Write(result, json: true); +Assert.Equal("{\"status\":\"ok\",\"value\":\"value\",\"messages\":[]}" + Environment.NewLine, + console.Stdout); +Assert.DoesNotContain("secret", console.AllWrittenText); +``` + +- [ ] **Step 3: Run the new tests and verify compilation failure** + +Run: + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter "FullyQualifiedName~ProjectResolutionTests|FullyQualifiedName~OutcomeWriterTests|FullyQualifiedName~SecretReaderTests" +``` + +Expected: failure because the workflow and CLI types do not exist. + +- [ ] **Step 4: Implement stable result and exit contracts** + +Define: + +```csharp +public enum WorkflowErrorKind { None, Usage, Validation, Authentication, Conflict, Cancelled } + +public sealed record WorkflowResult( + string Status, + T? Value, + IReadOnlyList Messages, + WorkflowErrorKind ErrorKind = WorkflowErrorKind.None, + IReadOnlyList? CompletedPhases = null); + +public enum CliExitCode +{ + Success = 0, + Usage = 2, + Validation = 3, + Authentication = 4, + Conflict = 5, + Cancelled = 130 +} + +public sealed record ResolvedAuthorProject(string ProjectPath, PluginRepoIndex Index); + +public interface ICliConsole +{ + TextReader In { get; } + TextWriter Out { get; } + TextWriter Error { get; } + bool IsInputRedirected { get; } + void WriteStatus(string message); +} +``` + +Map `WorkflowErrorKind` to the exact exit codes above. + +- [ ] **Step 5: Implement project resolution and a project lease** + +`AuthorProjectContext.ResolveAsync(string? explicitPath, string currentDirectory, CancellationToken ct)` returns `ResolvedAuthorProject` and must apply explicit path, current directory, then saved project ordering. `AcquireWriteLeaseAsync(string projectPath, CancellationToken ct)` returns the `FileStream` from a `.amm-author.lock` file under the project folder through `CrossProcessFileLock.AcquireAsync`. Read-only commands and every `--dry-run` path do not acquire the write lease or create a lock file. + +- [ ] **Step 6: Implement JSON payload and console primitives** + +`JsonPayloadService.ReadAsync(string source, TextReader stdin, CancellationToken ct)` accepts a UTF-8 file or `-` for standard input, uses camelCase JSON options, and rejects null documents. `SecretReader.ReadAsync(ICliConsole console, CancellationToken ct)` reads one key at a time, masks nothing, echoes nothing, supports backspace, ends on Enter, and throws `OperationCanceledException` on Ctrl+C. + +- [ ] **Step 7: Create the console host** + +Use this CLI project core: + +```xml + + + + + + + + + + + + + + Exe + net10.0-windows + enable + enable + amm-author + 0.28.0 + + + $(DefineConstants);REGISTRY_ADMIN + + +``` + +Register `--version`, `--json`, `--quiet`, `--project`, `--dry-run`, and `--yes` at the root. Catch `OperationCanceledException` and return 130; map typed workflow errors without stack traces unless `--verbose` is present. + +- [ ] **Step 8: Run focused and full tests** + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter "FullyQualifiedName~AuthorCli" +dotnet test AccessibilityModManager.slnx +dotnet run --project src/AccessibilityModManager.AuthorCli -- --version +``` + +Expected: focused and full tests pass; version output is `0.28.0`. + +- [ ] **Step 9: Commit the CLI foundation** + +```powershell +git add AccessibilityModManager.slnx src tests +git commit -m "feat: add Author CLI foundation" +``` + +--- + +### Task 3: Implement Project, Author, Game, Dependency, and Script Commands + +**Files:** +- Create: `src/AccessibilityModManager.Authoring/Workflows/CatalogWorkflow.cs` +- Create: `src/AccessibilityModManager.Authoring/Workflows/DependencyPresetCatalog.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/ProjectCommands.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/AuthorCommands.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/GameCommands.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/DependencyCommands.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/ScriptCommands.cs` +- Test: `tests/AccessibilityModManager.Tests/Authoring/CatalogWorkflowTests.cs` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/CatalogCommandTests.cs` + +**Interfaces:** +- Produces: `CatalogWorkflow.CreateProject`, `SetAuthor`, `AddGame`, `UpdateGame`, `RemoveGame`, `UpsertDependency`, `RemoveDependency`, `SetLifecycleScript`, and `ClearLifecycleScript`. +- Consumes: `PluginRepoIndex`, `GameDefinition`, `Dependency`, `LifecycleScript`, `IndexFileService`, `JsonPayloadService`, and `AuthorProjectContext`. + +- [ ] **Step 1: Write lossless catalog-mutation tests** + +Create complete model fixtures containing tags, languages, probe rules, registry probes, ASCII path shims, dependency checks, fixes, auto-install actions, version discovery, and all lifecycle script fields. Assert round-trip preservation and targeted mutation: + +```csharp +var changed = workflow.UpsertDependency(index, "ffviinew", replacement); +Assert.Equal(replacement, changed.Games.Single(g => g.GameId == "ffviinew").Dependencies.Single(d => d.Id == replacement.Id)); +Assert.Equal(original.ReleasesByGameId, changed.ReleasesByGameId); +``` + +Also assert duplicate game ids and duplicate dependency ids are rejected case-insensitively. + +- [ ] **Step 2: Write command tests for human and JSON input** + +Exercise `project init`, `project status`, `author set --input`, `game add --input`, `game update --input`, `game remove`, `dependency set --input`, `dependency remove`, `script set --input`, and `script clear`. Assert `--dry-run` leaves `index.json` byte-identical. + +- [ ] **Step 3: Run focused tests and verify failure** + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter "FullyQualifiedName~CatalogWorkflowTests|FullyQualifiedName~CatalogCommandTests" +``` + +Expected: failure because catalog workflows and commands are absent. + +- [ ] **Step 4: Implement immutable catalog mutations** + +Each method returns a new `PluginRepoIndex` and preserves unrelated data. Define exact signatures: + +```csharp +public PluginRepoIndex SetAuthor(PluginRepoIndex index, PluginAuthorInfo? author); +public PluginRepoIndex CreateProject(string pluginId); +public PluginRepoIndex AddGame(PluginRepoIndex index, GameDefinition game); +public PluginRepoIndex UpdateGame(PluginRepoIndex index, string currentGameId, GameDefinition replacement); +public PluginRepoIndex RemoveGame(PluginRepoIndex index, string gameId); +public PluginRepoIndex UpsertDependency(PluginRepoIndex index, string gameId, Dependency dependency); +public PluginRepoIndex RemoveDependency(PluginRepoIndex index, string gameId, string dependencyId); +public PluginRepoIndex SetLifecycleScript(PluginRepoIndex index, string gameId, LifecycleSlot slot, LifecycleScript script); +public PluginRepoIndex ClearLifecycleScript(PluginRepoIndex index, string gameId, LifecycleSlot slot); + +public enum LifecycleSlot { PreInstall, PostInstall, PostUninstall } +``` + +On a game-id rename, update the `Games` entry and `ReleasesByGameId` key, but refuse releases whose embedded `GameId` would no longer match unless the caller passes `--rewrite-release-game-id` and confirms the preview. + +- [ ] **Step 5: Register project and catalog commands** + +Use `--input ` for complete camelCase models so every current and future field remains expressible. Add `project init`, `recent`, `open`, `clone`, `pull`, `repos`, and `status`; add `author show` and `set`; add complete game, dependency, and script CRUD. Move the current dependency preset definitions from `ViewModels/DependencyPresets.cs` into `DependencyPresetCatalog`, make the WPF view model consume that catalog, and add `dependency presets` plus `dependency apply-preset`. Add concise flags for common game fields: `--id`, `--display-name`, `--mod-name`, `--description`, `--steam-app-id`, `--exe-name`, repeated `--tag`, and repeated `--language`. When both an input document and field flags are supplied, reject the command with exit code 2. + +- [ ] **Step 6: Validate before every durable save** + +Serialize the candidate index, run `PluginIndexValidation.Validate(candidate.PluginId, json)`, and refuse any `PublishBlockers`. There is no CLI flag that saves a candidate rejected by the shared validator. + +- [ ] **Step 7: Run tests and command smoke checks** + +```powershell +dotnet test AccessibilityModManager.slnx +dotnet run --project src/AccessibilityModManager.AuthorCli -- project --help +dotnet run --project src/AccessibilityModManager.AuthorCli -- game --help +dotnet run --project src/AccessibilityModManager.AuthorCli -- dependency --help +dotnet run --project src/AccessibilityModManager.AuthorCli -- script --help +``` + +Expected: all tests pass and each help command lists its complete subcommands. + +- [ ] **Step 8: Commit catalog authoring** + +```powershell +git add src tests +git commit -m "feat: add catalog authoring commands" +``` + +--- + +### Task 4: Implement Wrapped Package Build and Validation Commands + +**Files:** +- Create: `src/AccessibilityModManager.Authoring/Workflows/PackageWorkflow.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/PackageCommands.cs` +- Test: `tests/AccessibilityModManager.Tests/Authoring/PackageWorkflowTests.cs` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/PackageCommandTests.cs` + +**Interfaces:** +- Produces: `PackageBuildRequest`, `PackageInspection`, `PackageWorkflow.BuildAsync`, and `PackageWorkflow.ValidateAsync`. +- Consumes: `ManifestBuilderService`, `PluginPackageValidation`, `Sha256HashService`, catalog dependencies, and lifecycle-script source mappings. + +- [ ] **Step 1: Write package behavior tests** + +Cover a file-only mod, folder content, an external lifecycle script, a script-only mod, mismatched game/plugin/version identity, a missing script, unsafe script paths, and cancellation. Assert validation reads the staged stream and returns its exact SHA256: + +```csharp +var result = await workflow.BuildAsync(request, CancellationToken.None); +Assert.True(result.Validation.IsValid); +Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(result.ZipPath))), result.Sha256); +``` + +- [ ] **Step 2: Write package command tests** + +Cover `package build`, `package validate`, and `package hash`. Verify `package build --dry-run` validates source and output paths without creating a ZIP. + +- [ ] **Step 3: Run focused tests and verify failure** + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter "FullyQualifiedName~PackageWorkflowTests|FullyQualifiedName~PackageCommandTests" +``` + +Expected: failure because package workflows and commands are absent. + +- [ ] **Step 4: Implement package workflow** + +Define: + +```csharp +public sealed record PackageBuildRequest( + string SourceFolder, + string OutputZipPath, + string PluginId, + string GameId, + string Version, + IReadOnlyList Dependencies, + LifecycleScriptInputs Scripts); + +public sealed record PackageInspection( + string ZipPath, + string Sha256, + int FileCount, + long TotalBytes, + PackageValidationReport Validation); +``` + +Build through `ManifestBuilderService`, reopen the finished ZIP read-only, validate through `PluginPackageValidation`, calculate the digest from that finished file, and delete the output if validation fails. + +- [ ] **Step 5: Register package commands** + +`package build` accepts `--source`, `--game`, `--version`, optional `--output`, and resolves plugin id, dependencies, and scripts from the project. `package validate` requires `--zip`, `--plugin`, `--game`, and `--version`. `package hash` outputs only the lowercase digest in normal mode and a named property in JSON mode. + +- [ ] **Step 6: Run all tests and inspect a disposable ZIP** + +```powershell +dotnet test AccessibilityModManager.slnx +dotnet run --project src/AccessibilityModManager.AuthorCli -- package build --project --source --game sample --version 1.0.0 +``` + +Expected: the package contains root `manifest.json` and content under `files/`; validation succeeds. + +- [ ] **Step 7: Commit package authoring** + +```powershell +git add src tests +git commit -m "feat: add package authoring commands" +``` + +--- + +### Task 5: Implement GitHub Release and Asset Publication + +**Files:** +- Create: `src/AccessibilityModManager.Authoring/Workflows/ReleaseWorkflow.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/GitHubCommands.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/ReleaseCommands.cs` +- Test: `tests/AccessibilityModManager.Tests/Authoring/ReleaseWorkflowTests.cs` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/ReleaseCommandTests.cs` + +**Interfaces:** +- Produces: `ReleasePublishRequest`, `ReleasePublishPreview`, `ReleasePublishResult`, `ReleaseWorkflow.PrepareAsync`, and `ReleaseWorkflow.PublishAsync`. +- Consumes: `GitHubService`, `PluginPackageValidation`, `Sha256HashService`, `CatalogWorkflow`, `AuthorConfigService`, and a read-locked staged package. + +- [ ] **Step 1: Write staged-byte and partial-result tests** + +Use a fake GitHub service boundary to prove the bytes hashed are the bytes uploaded, an existing tag edits notes rather than creating a second release, an existing asset is replaced only after confirmation, private repositories are refused, and upload success plus catalog-save failure returns completed phase `githubAssetUploaded` with exit category Conflict. + +- [ ] **Step 2: Write release command tests** + +Cover `release list`, `release show`, `release add --input`, `release edit --input`, `release remove`, `release upload`, and `release publish`. Verify release identity is `(version, channel)` and edits remove the old identity before inserting the new one. + +- [ ] **Step 3: Run focused tests and verify failure** + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter "FullyQualifiedName~ReleaseWorkflowTests|FullyQualifiedName~ReleaseCommandTests" +``` + +Expected: failure because release workflows and commands are absent. + +- [ ] **Step 4: Extract staging from the WPF release view model** + +Move the current `StagedPackage` behavior into Authoring as an internal disposable type. Preserve leaf-name validation, private temporary directory creation, write exclusion, digest calculation, and cleanup byte-for-byte. Replace the WPF view model's private implementation with `ReleaseWorkflow.PrepareAsync`. + +- [ ] **Step 5: Implement GitHub release workflow** + +Define: + +```csharp +public sealed record ReleasePublishRequest( + string ProjectPath, + string PluginId, + string GameId, + string Version, + string Channel, + string SourceRepo, + string LocalZipPath, + string? AssetFileName, + string? Notes, + string? ChangelogUrl, + PatreonGate? Patreon); + +public sealed record ReleasePublishPreview( + string Repository, + string Tag, + string AssetFileName, + string Sha256, + bool CreatesRelease, + bool ReplacesAsset); + +public sealed record ReleasePublishResult( + ModRelease Release, + string AssetUrl, + string Sha256, + IReadOnlyList CompletedPhases); + +public sealed class PreparedRelease : IAsyncDisposable +{ + public ReleasePublishPreview Preview { get; } + public string StagedPath { get; } + public string Sha256 { get; } + public ValueTask DisposeAsync(); +} + +public Task> PrepareAsync( + ReleasePublishRequest request, CancellationToken ct); + +public Task> PublishAsync( + PreparedRelease prepared, ReleasePublishRequest request, bool confirmed, CancellationToken ct); +``` + +Preparation validates all metadata and package identity before upload. Publication uses the existing `GitHubService` methods and returns the public asset URL plus exact staged SHA256. It does not save or publish `index.json`; Task 7 composes that transaction. + +- [ ] **Step 6: Register GitHub and release commands** + +Add `github status`, `github repos`, and `github releases --repo`. Add complete release CRUD plus `release upload`. `release publish` is registered now but reports a clear unavailable-phase result until Task 7 supplies index publication; its parser contract is pinned by tests. + +- [ ] **Step 7: Run regression and disposable fake-publish tests** + +```powershell +dotnet test AccessibilityModManager.slnx +``` + +Expected: every test passes and no real GitHub repository was modified. + +- [ ] **Step 8: Commit release publication** + +```powershell +git add src tests +git commit -m "feat: add GitHub release authoring" +``` + +--- + +### Task 6: Implement Index Reconciliation, Validation, and Publication + +**Files:** +- Create: `src/AccessibilityModManager.Authoring/Workflows/IndexWorkflow.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/IndexCommands.cs` +- Modify: `src/AccessibilityModManager.AuthorCli/Commands/ReleaseCommands.cs` +- Modify: `src/AccessibilityModManager.AuthorTool/ViewModels/IndexEditorViewModel.cs` +- Test: `tests/AccessibilityModManager.Tests/Authoring/IndexWorkflowTests.cs` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/IndexCommandTests.cs` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/CompleteReleasePublishTests.cs` + +**Interfaces:** +- Produces: `IndexPublishRequest`, `IndexPublishPreview`, `IndexPublishResult`, `IndexWorkflow.Validate`, `ReconcileAsync`, `SaveAsync`, `PublishAsync`, `InspectLockAsync`, and `BreakLockAsync`. +- Consumes: `ProjectReconciler`, `IndexPublishCoordinator`, `GitHubIndexPublisher`, `UnsignedPublishGate`, `RegistryMembershipChecker`, `ServerUploadService`, and `AuthorConfigService` publishing records. + +- [ ] **Step 1: Write reconciliation and publish tests** + +Cover local-equals-last-published, live advanced elsewhere, local unpublished edits, unreadable live state, registered URL mismatch, private GitHub repository, missing destination, signed catalog on an unsigned path, GitHub publish, server publish, lock contention, compare-before-break, and read-back mismatch. + +- [ ] **Step 2: Write complete release transaction tests** + +Assert this exact phase order: + +```csharp +Assert.Equal(new[] { + "projectLocked", "catalogReconciled", "packageValidated", "assetUploaded", + "releaseRecorded", "indexValidated", "indexSaved", "indexPublished", "liveVerified" +}, result.CompletedPhases); +``` + +Simulate failure after each phase and verify the result lists only phases that actually completed. + +- [ ] **Step 3: Run focused tests and verify failure** + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter "FullyQualifiedName~IndexWorkflowTests|FullyQualifiedName~IndexCommandTests|FullyQualifiedName~CompleteReleasePublishTests" +``` + +Expected: failure because index workflow and composed release publication are absent. + +- [ ] **Step 4: Extract index orchestration into the shared workflow** + +Move non-UI logic from `IndexEditorViewModel` into `IndexWorkflow` while leaving UI messages and prompts in the view model. The workflow must return a preview before mutation and accept a separate confirmed execution call. Preserve the existing second authorization check immediately before GitHub push. + +Use these contracts: + +```csharp +public sealed record IndexPublishRequest( + string ProjectPath, + PluginRepoIndex Candidate, + PublishDestination Destination, + string CommitMessage, + bool DryRun); + +public sealed record IndexPublishPreview( + string PluginId, + PublishDestination Destination, + string DestinationDescription, + string CommitMessage, + IReadOnlyList CatalogChanges); + +public sealed record IndexPublishResult( + string PluginId, + string PublishedSha256, + string DestinationDescription, + IReadOnlyList CompletedPhases); + +public IndexValidationReport Validate(PluginRepoIndex candidate); +public Task> ReconcileAsync(string projectPath, CancellationToken ct); +public Task> SaveAsync(string projectPath, PluginRepoIndex candidate, bool dryRun, CancellationToken ct); +public Task> PreviewPublishAsync(IndexPublishRequest request, CancellationToken ct); +public Task> PublishAsync(IndexPublishRequest request, bool confirmed, CancellationToken ct); +public Task> InspectLockAsync(string pluginId, CancellationToken ct); +public Task> BreakLockAsync(string pluginId, string expectedFingerprint, bool confirmed, CancellationToken ct); +``` + +- [ ] **Step 5: Implement index commands** + +Add `index show`, `index validate`, `index reconcile`, `index save`, `index destination get`, `index destination set`, `index membership`, `index publish`, `index lock show`, and `index lock break`. `index lock break` requires the displayed lock fingerprint and confirmation; if the lock changes, it refuses. + +- [ ] **Step 6: Complete `release publish` composition** + +Acquire the project lease, reconcile, prepare and validate the package, publish the asset, add the release in memory, validate and durably save the index, publish the selected destination, verify the live index, and record the published digest. Emit a phase line after each completed phase. + +- [ ] **Step 7: Run all tests and local Git integration** + +```powershell +dotnet test AccessibilityModManager.slnx +``` + +Use a disposable working repository with a local bare remote to verify commit, push, branch creation, CRLF normalization behavior, and live blob read-back without network writes. + +- [ ] **Step 8: Commit index publication** + +```powershell +git add src tests +git commit -m "feat: add safe catalog publication" +``` + +--- + +### Task 7: Implement Patreon and SFTP Server Parity + +**Files:** +- Create: `src/AccessibilityModManager.Authoring/Workflows/PatreonWorkflow.cs` +- Create: `src/AccessibilityModManager.Authoring/Workflows/ServerWorkflow.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/PatreonCommands.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/ServerCommands.cs` +- Modify: `src/AccessibilityModManager.Authoring/Workflows/ReleaseWorkflow.cs` +- Modify: `src/AccessibilityModManager.Authoring/Workflows/IndexWorkflow.cs` +- Test: `tests/AccessibilityModManager.Tests/Authoring/PatreonWorkflowTests.cs` +- Test: `tests/AccessibilityModManager.Tests/Authoring/ServerWorkflowTests.cs` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/PatreonServerCommandTests.cs` + +**Interfaces:** +- Produces: Patreon session, tier, post, and attachment results; server configuration, self-test, release upload, gate update, and lock results. +- Consumes: `PatreonAuthorService`, `ServerUploadService`, `ServerSelfTest`, and DPAPI-aware `AuthorConfigService`. + +- [ ] **Step 1: Write Patreon command and workflow tests** + +Cover signed-out status, sign-in cancellation, sign-out, tier refresh, invalid post URL, numeric post extraction, attachment selection, no selected tiers, no campaign id, and both Patreon-post and author-server delivery modes. + +- [ ] **Step 2: Write server command and workflow tests** + +Cover configuration validation, missing key, host-key mismatch, connection test steps, public upload, gated upload, refusing different bytes at an existing version, gate-only update, gate removal after catalog publication, publish-lock inspection, and changed-lock refusal. + +- [ ] **Step 3: Run focused tests and verify failure** + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter "FullyQualifiedName~PatreonWorkflowTests|FullyQualifiedName~ServerWorkflowTests|FullyQualifiedName~PatreonServerCommandTests" +``` + +Expected: failure because the workflows and commands are absent. + +- [ ] **Step 4: Implement Patreon workflow and commands** + +Add `patreon status`, `login`, `logout`, `tiers`, and `post validate`. Browser-based OAuth remains the existing service behavior. `post validate` outputs every attachment with its file name and stable selection id. + +Use these contracts: + +```csharp +public sealed record PatreonSessionStatus(bool IsSignedIn, string? MemberName, string? CampaignId); +public sealed record PatreonTierInfo(string TierId, string DisplayName); +public sealed record PatreonAttachmentInfo(string SelectionId, string FileName, string? DownloadUrl); +public sealed record PatreonPostInspection(string PostId, IReadOnlyList Attachments); + +public Task> GetStatusAsync(CancellationToken ct); +public Task> SignInAsync(CancellationToken ct); +public Task> SignOutAsync(CancellationToken ct); +public Task>> GetTiersAsync(CancellationToken ct); +public Task> InspectPostAsync(string postUrl, CancellationToken ct); +``` + +- [ ] **Step 5: Implement server workflow and secret-safe configuration** + +Add `server status`, `configure`, `clear`, `test`, `self-test`, `release inspect`, `release upload`, `gate set`, `gate remove`, `lock show`, and `lock break`. Read the SSH key passphrase only through concealed input or `--passphrase-stdin`, then persist it through the existing DPAPI-aware configuration service. + +Wrap the existing sealed service behind a testable adapter without changing it: + +```csharp +public sealed record ServerConfigurationInput(ServerUploadConfig Config, string KeyPassphrase); +public sealed record ServerConnectionReport(bool Connected, IReadOnlyList Steps); +public sealed record ServerReleaseRequest( + string GameId, string Version, string AssetFileName, string LocalZipPath, PatreonGate? Gate); + +public interface IServerAuthorTransport +{ + Task TestAsync(ServerUploadConfig config, CancellationToken ct); + Task PublishReleaseAsync( + ServerUploadConfig config, ServerReleaseRequest request, CancellationToken ct); + Task PublishGateAsync(ServerUploadConfig config, string gameId, string version, PatreonGate gate, CancellationToken ct); + Task RemoveGateAsync(ServerUploadConfig config, string gameId, string version, CancellationToken ct); +} +``` + +- [ ] **Step 6: Integrate gated release sequencing** + +For a gated release, upload the package and fresh gate first, publish the index second, then apply a changed or removed gate only after the live catalog matches. Preserve the WPF flow's public-URL reachability check when removing a gate. + +- [ ] **Step 7: Run all tests** + +```powershell +dotnet test AccessibilityModManager.slnx +``` + +Expected: all tests pass and test configuration stays under disposable override directories. + +- [ ] **Step 8: Commit Patreon and server parity** + +```powershell +git add src tests +git commit -m "feat: add Patreon and server authoring" +``` + +--- + +### Task 8: Implement Signing and Registry-Admin Parity + +**Files:** +- Create: `src/AccessibilityModManager.Authoring/Workflows/AuthoringBuildFlags.cs` +- Create: `src/AccessibilityModManager.Authoring/Workflows/SigningWorkflow.cs` +- Create: `src/AccessibilityModManager.Authoring/Workflows/RegistryAdminWorkflow.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/SigningCommands.cs` +- Create: `src/AccessibilityModManager.AuthorCli/Commands/RegistryCommands.cs` +- Modify: `src/AccessibilityModManager.AuthorTool/BuildFlags.cs` +- Modify: `src/AccessibilityModManager.AuthorTool/App.xaml.cs` +- Test: `tests/AccessibilityModManager.Tests/Authoring/SigningWorkflowTests.cs` +- Test: `tests/AccessibilityModManager.Tests/Authoring/RegistryAdminWorkflowTests.cs` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/SigningRegistryCommandTests.cs` + +**Interfaces:** +- Produces: signing-key lifecycle operations, claim previews and signatures, publisher-head operations, registry validation and signature operations, and build-gated registry commands. +- Consumes: `ClaimSigningKeyStore`, `PublisherHeadStore`, `IndexProofService`, `ClaimSetBuilder`, `ClaimSigner`, `ServerUploadService`, `GitService`, and `GitHubService`. + +- [ ] **Step 1: Write signing lifecycle tests** + +Cover create, status, export, import, wrong passphrase, passphrase change, public-key mismatch, imported recordless key refusal, pending publish recovery, confirmation, and head reconciliation. Assert secrets never appear in workflow messages. + +- [ ] **Step 2: Write standard/admin build-gate tests** + +In a standard build, `registry status` must return exit code 4 with the explanation that an admin build is required. In an admin build, parser and workflow tests cover open/clone, refresh, JSON validation, signature creation, registry-pair upload, read-back, commit, and push. + +- [ ] **Step 3: Run focused tests and verify failure** + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter "FullyQualifiedName~SigningWorkflowTests|FullyQualifiedName~RegistryAdminWorkflowTests|FullyQualifiedName~SigningRegistryCommandTests" +``` + +Expected: failure because signing and registry workflows are absent. + +- [ ] **Step 4: Implement shared build flag** + +Define: + +```csharp +public static class AuthoringBuildFlags +{ +#if REGISTRY_ADMIN + public const bool IsRegistryAdmin = true; +#else + public const bool IsRegistryAdmin = false; +#endif +} +``` + +Make the WPF `BuildFlags.IsRegistryAdmin` delegate to this value. Pass `RegistryAdmin=$(RegistryAdmin)` to both Authoring and CLI projects in build commands. + +- [ ] **Step 5: Implement signing commands** + +Add `signing status`, `create`, `export`, `import`, `change-passphrase`, `claims preview`, `claims sign`, `head status`, `head confirm`, `head commit-pending`, and `head resume`. Secret input follows the global rule, and commands call existing stores and proof services without alternate validation. + +Use these workflow contracts: + +```csharp +public sealed record SigningKeyStatus( + string PluginId, string KeyId, string PublicKeyFingerprint, bool ImportedFromBackup, bool HasPublisherHead); +public sealed record ClaimPublishPreview( + string PluginId, string KeyId, long PublishNumber, IReadOnlyList Changes, string DeletionsToken); + +public WorkflowResult GetStatus(string pluginId); +public WorkflowResult Create(string pluginId, string passphrase); +public WorkflowResult Export(string pluginId, string destination, string exportPassphrase); +public WorkflowResult Import(string source, string importPassphrase); +public WorkflowResult ChangePassphrase(string pluginId, string currentPassphrase, string newPassphrase); +public Task> PreviewClaimsAsync(string projectPath, CancellationToken ct); +public Task> SignClaimsAsync( + string projectPath, string deletionsToken, bool confirmed, CancellationToken ct); +``` + +- [ ] **Step 6: Implement registry-admin commands** + +Add `registry status`, `open`, `refresh`, `json show`, `json validate`, `json save`, `sign`, `publish`, `commit`, and `push`. Standard builds register the group so help remains discoverable but every handler refuses before reading private configuration. + +Use these workflow contracts: + +```csharp +public sealed record RegistryDocumentResult(string Path, string Sha256, bool SignaturePresent); +public sealed record RegistryPublishResult( + string Destination, string JsonSha256, string SignatureSha256, IReadOnlyList CompletedPhases); + +public WorkflowResult Validate(string registryJsonPath); +public WorkflowResult Sign( + string registryJsonPath, string privateKeyPath, string passphrase, bool confirmed); +public Task> PublishAsync( + string registryRepoPath, bool confirmed, CancellationToken ct); +public Task> CommitAsync( + string registryRepoPath, string message, CancellationToken ct); +public Task> PushAsync(string registryRepoPath, CancellationToken ct); +``` + +- [ ] **Step 7: Run both build variants and all tests** + +```powershell +dotnet build AccessibilityModManager.slnx +dotnet build src/AccessibilityModManager.AuthorCli/AccessibilityModManager.AuthorCli.csproj -p:RegistryAdmin=true +dotnet test AccessibilityModManager.slnx +dotnet test AccessibilityModManager.slnx -p:RegistryAdmin=true +``` + +Expected: both builds succeed and all tests pass. + +- [ ] **Step 8: Commit signing and registry parity** + +```powershell +git add src tests +git commit -m "feat: add signing and registry CLI parity" +``` + +--- + +### Task 9: Complete Command Discovery, Help, JSON, and WPF Parity + +**Files:** +- Create: `src/AccessibilityModManager.AuthorCli/Commands/CommandCatalog.cs` +- Modify: all files under `src/AccessibilityModManager.AuthorCli/Commands/` +- Modify: `src/AccessibilityModManager.AuthorTool/App.xaml.cs` +- Modify: affected AuthorTool view models under `src/AccessibilityModManager.AuthorTool/ViewModels/` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/CommandCoverageTests.cs` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/AccessibilityOutputTests.cs` +- Test: `tests/AccessibilityModManager.Tests/Authoring/GuiCliParityTests.cs` + +**Interfaces:** +- Produces: a complete searchable command tree, stable JSON envelopes, and shared GUI/CLI workflow decisions. +- Consumes: every workflow from Tasks 2 through 8. + +- [ ] **Step 1: Write command-coverage tests from a required inventory** + +Pin this top-level inventory: + +```csharp +var required = new[] { + "project", "author", "game", "dependency", "script", "package", "release", + "index", "github", "patreon", "server", "signing", "registry" +}; +Assert.Equal(required, CommandCatalog.TopLevelNames); +``` + +For every group, assert all subcommands listed in the design are present and have a nonempty description and example. + +- [ ] **Step 2: Write accessibility-output tests** + +Reject carriage-return progress rewriting, ANSI escape sequences by default, messages whose only content is punctuation, and JSON mode with multiple standard-output documents. Verify `--quiet` still emits warnings and failures. + +- [ ] **Step 3: Write GUI/CLI parity tests** + +Feed the same fixture and confirmed preview into the shared workflow from a WPF-facing adapter and a CLI-facing adapter. Assert equal candidate index bytes, package validation reports, release metadata, destination decisions, and completed phases. + +- [ ] **Step 4: Run focused tests and verify failures** + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter "FullyQualifiedName~CommandCoverageTests|FullyQualifiedName~AccessibilityOutputTests|FullyQualifiedName~GuiCliParityTests" +``` + +Expected: failures identify unregistered commands, incomplete help, or remaining GUI-only orchestration. + +- [ ] **Step 5: Finish command registration and examples** + +Centralize registration in `CommandCatalog` and make each group expose `Create(IServiceProvider services)`. Add examples to help text using Windows quoting and absolute-path examples that do not contain this user's private paths. + +- [ ] **Step 6: Finish WPF workflow adoption** + +Replace remaining duplicated non-UI decisions in WPF view models with shared workflow calls. Keep existing observable properties, command names, dialog text, focus behavior, and screen-reader announcements intact. + +- [ ] **Step 7: Run all tests and help snapshot** + +```powershell +dotnet test AccessibilityModManager.slnx +dotnet run --project src/AccessibilityModManager.AuthorCli -- --help +dotnet run --project src/AccessibilityModManager.AuthorCli -- release publish --help +``` + +Expected: all tests pass; help is complete and readable as plain text. + +- [ ] **Step 8: Commit parity and help** + +```powershell +git add src tests +git commit -m "feat: complete AuthorTool CLI parity" +``` + +--- + +### Task 10: Build, Document, Install, and Verify the Local CLI + +**Files:** +- Create: `installer/build-author-cli.ps1` +- Create: `src/AccessibilityModManager.AuthorCli/Properties/PublishProfiles/win-x64.pubxml` +- Modify: `README.md` +- Test: `tests/AccessibilityModManager.Tests/AuthorCli/PublishedCliSmokeTests.cs` + +**Interfaces:** +- Produces: local `amm-author.exe`, optional `amm-author-admin.exe`, build hashes, PATH installation, and documented commands. +- Consumes: completed CLI and existing .NET 10 SDK. + +- [ ] **Step 1: Write published-binary smoke test** + +The test launches a supplied published executable with `--version`, `--help`, and `project status --project --json`, then asserts exit code 0, version 0.28.0, parseable JSON, and no WPF process or window requirement. + +- [ ] **Step 2: Run the smoke test and verify it skips or fails without a published path** + +```powershell +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter FullyQualifiedName~PublishedCliSmokeTests +``` + +Expected: an explicit skipped result when `AMM_AUTHOR_CLI_EXE` is absent; the test never guesses a binary path. + +- [ ] **Step 3: Add the build script** + +The script accepts `-Configuration`, `-Version`, `-SelfContained`, and `-Admin`, reads version 0.28.0 from the CLI project when omitted, publishes `win-x64` single-file output, writes standard and admin builds to separate local folders, and prints a lowercase SHA256. It does not create or upload a GitHub release. + +- [ ] **Step 4: Add documentation** + +Document installation, project resolution, every command group, human versus JSON output, secret input, exit codes, dry-run and confirmation behavior, standard versus registry-admin builds, local-only license restriction, and five complete examples: inspect project, build package, validate package, publish a release, and dry-run an index publish. + +- [ ] **Step 5: Build both local variants** + +```powershell +powershell -ExecutionPolicy Bypass -File installer/build-author-cli.ps1 -SelfContained +powershell -ExecutionPolicy Bypass -File installer/build-author-cli.ps1 -SelfContained -Admin +``` + +Expected: `amm-author.exe` and `amm-author-admin.exe` are produced in separate local dist folders with hashes. + +- [ ] **Step 6: Run complete verification** + +```powershell +dotnet test AccessibilityModManager.slnx +$env:AMM_AUTHOR_CLI_EXE = (Resolve-Path 'dist-author-cli/amm-author.exe') +dotnet test tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj --filter FullyQualifiedName~PublishedCliSmokeTests +``` + +Expected: all solution and published-binary tests pass. + +- [ ] **Step 7: Install locally and update user PATH** + +Copy the two executables and their `.sha256` files to `C:\Users\buu42\Tools\AccessibilityModManager`. Add that exact directory to the current-user PATH only when its case-insensitive normalized entry is absent. Do not copy source or binaries into Blind Soldier, `buu-s-mods`, or any GitHub checkout intended for publication. + +- [ ] **Step 8: Verify outside the source tree** + +Run from `C:\Users\buu42`: + +```powershell +amm-author --version +amm-author --help +amm-author project status --project --json +``` + +Expected: version 0.28.0, complete help, and a successful JSON status result. + +- [ ] **Step 9: Confirm no external publication occurred** + +```powershell +git status --short +git log --oneline --decorate -12 +git remote -v +``` + +Expected: only local commits exist on `local/full-author-cli`; the remote remains read-only upstream and no push was performed. + +- [ ] **Step 10: Commit build and documentation** + +```powershell +git add installer src/AccessibilityModManager.AuthorCli/Properties README.md tests +git commit -m "build: package local Author CLI" +``` + +## Final Verification Checklist + +- [ ] `dotnet build AccessibilityModManager.slnx` succeeds. +- [ ] `dotnet build src/AccessibilityModManager.AuthorCli/AccessibilityModManager.AuthorCli.csproj -p:RegistryAdmin=true` succeeds. +- [ ] `dotnet test AccessibilityModManager.slnx` passes without skipped behavioral tests. +- [ ] Standard CLI refuses registry administration with exit code 4 and a readable explanation. +- [ ] Admin CLI runs registry read-only status against a disposable fixture. +- [ ] Human output contains no ANSI sequences or rewritten lines. +- [ ] JSON mode writes exactly one result object to standard output. +- [ ] Secrets do not appear in process arguments, stdout, stderr, or logs. +- [ ] `--dry-run` leaves fixture files and Git history byte-identical. +- [ ] Package build and validation succeed for a disposable mod. +- [ ] Complete release publication succeeds against fake GitHub and local Git boundaries. +- [ ] Patreon and SFTP flows pass controlled integration tests without touching live accounts or servers. +- [ ] Existing WPF AuthorTool tests and behavior remain green. +- [ ] Installed `amm-author.exe` runs from outside the checkout. +- [ ] No fork, push, pull request, release, or redistribution occurred. diff --git a/docs/superpowers/specs/2026-08-04-full-author-cli-design.md b/docs/superpowers/specs/2026-08-04-full-author-cli-design.md new file mode 100644 index 0000000..de07139 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-full-author-cli-design.md @@ -0,0 +1,199 @@ +# Full Accessibility Mod Manager Author CLI Design + +## Purpose + +Build a local, screen-reader-friendly command-line counterpart to RealAmethyst's current Accessibility Mod Manager AuthorTool. The CLI must provide feature parity with the AuthorTool while using the same models, validation rules, trust checks, packaging code, and publishing behavior. It is intended to let Codex and the user maintain Blind Soldier and other accessibility-mod catalogs without operating the WPF interface. + +Development starts from upstream commit `9e2d223762aa21a2fc765bae55380699a2532746`, where the AuthorTool is version 0.28.0. The older local `PluginIndexAuthor-0.25.1.exe` was inspected only to identify the user's existing workflow and is not an implementation base. + +This is a local personal-use modification. Nothing will be forked, pushed, submitted upstream, or redistributed until the user has spoken with RealAmethyst and obtained any permission that is required. No protected security or integrity mechanism will be weakened, bypassed, or replaced. + +## Chosen Approach + +Add a console application to the current Accessibility Mod Manager solution and move reusable non-UI author workflows into a shared authoring library. Both the existing WPF AuthorTool and the CLI will call those shared workflows. + +This is preferred over the alternatives: + +- Referencing the WPF executable directly would pull UI state and dialog callbacks into console operations and would be fragile as the GUI changes. +- Automating the AuthorTool window would retain the accessibility and focus problems that motivated the CLI. +- Independently reimplementing the JSON and publishing rules would allow the GUI and CLI to disagree and could bypass later safety fixes. + +## Project Structure + +Add these projects: + +- `AccessibilityModManager.Authoring`: a non-UI class library containing author workflow orchestration, typed command inputs and results, and reusable services currently embedded in the AuthorTool project or its view models. +- `AccessibilityModManager.AuthorCli`: a `net10.0-windows` console application published as `amm-author.exe`. + +Keep these existing projects: + +- `AccessibilityModManager.Core`: public models and interfaces. +- `AccessibilityModManager.Infrastructure`: installer, validation, security, network, and persistence implementation. +- `AccessibilityModManager.AuthorTool`: the WPF interface. It will consume the shared authoring library without losing existing behavior. + +The shared library will expose focused workflows rather than UI abstractions: + +- `ProjectWorkflow` +- `GameWorkflow` +- `DependencyWorkflow` +- `LifecycleScriptWorkflow` +- `PackageWorkflow` +- `ReleaseWorkflow` +- `IndexWorkflow` +- `PatreonWorkflow` +- `ServerWorkflow` +- `SigningWorkflow` +- `RegistryAdminWorkflow` + +Each workflow accepts typed input records and returns typed result records. Decisions that require consent return a preview describing the exact action. The WPF caller presents its existing dialog; the CLI caller presents a text prompt or requires `--yes` in noninteractive mode. + +## Command Surface + +Use `System.CommandLine` 2.0.10 for parsing, help, validation, and completion support. Commands are grouped by the AuthorTool feature they represent: + +```text +amm-author project ... +amm-author author ... +amm-author game ... +amm-author dependency ... +amm-author script ... +amm-author package ... +amm-author release ... +amm-author index ... +amm-author github ... +amm-author patreon ... +amm-author server ... +amm-author signing ... +amm-author registry ... +``` + +The command families cover: + +- Project creation, recent projects, opening local projects, listing writable GitHub repositories, cloning, pulling, and project status. +- Author profile display and editing. +- Game creation, editing, removal, and listing. +- Tags, languages, filters, executable and Steam detection fields, dependencies, dependency presets, check rules, and auto-install metadata. +- Pre-install, post-install, and post-uninstall lifecycle scripts, including external source paths and install-to-game-folder behavior. +- Wrapped package building, manifest generation, package validation, and SHA256 calculation. +- Release listing, creation, editing, removal, URL-only releases, GitHub uploads, server uploads, channels, notes, changelog links, and Patreon gates. +- Index loading, formatting, validation, reconciliation with the live catalog, publish-destination selection, saving, publishing, membership checks, and status. +- GitHub CLI availability, authentication, repository listing, release creation or update, asset replacement, index commit, and push. +- Patreon sign-in, sign-out, status, tier listing, post validation, attachment selection, and gated-release metadata. +- SFTP settings, host-key pinning, connection tests, public and gated asset upload, server self-test, and publish-lock inspection or removal. +- Catalog signing-key creation, import, export, backup, passphrase change, status, claim signing, head recovery, and reconciliation. +- Registry-admin project handling, JSON editing and validation, signing, registry-pair publication, release publication, and commit/push. + +Registry-admin commands are compiled only when the existing `RegistryAdmin` build property is enabled. A standard build will explain that the command is unavailable instead of silently omitting the reason. The CLI will not add an option that bypasses this build gate. + +## Project Resolution + +Commands that operate on a plugin catalog resolve the project in this order: + +1. The explicit `--project ` option. +2. The current directory when it contains `index.json`. +3. The last-opened project in `%LocalAppData%\AccessibilityModManager-Author\config.json`. + +The resolved absolute path and plugin id are printed before a mutating operation. The CLI shares the AuthorTool's existing configuration, recent-project list, source-repository mappings, script paths, server settings, DPAPI-protected credentials, signing keys, and publishing records. + +## Complete Release Flow + +Granular commands remain available, but `amm-author release publish` provides the AuthorTool's complete normal release workflow: + +1. Resolve and lock the project. +2. Load the author configuration and `index.json`. +3. Reconcile local and live catalog state using the existing trust rules. +4. Build or select a wrapped ZIP. +5. Copy the package into a private staging directory and hold it read-only. +6. Validate the manifest through `PluginPackageValidation` using the requested plugin id, game id, and version. +7. Hash the exact staged bytes. +8. Check the chosen GitHub, server, or Patreon destination. +9. Upload the same staged bytes that were validated and hashed. +10. Add or replace the release record in memory. +11. Validate the complete plugin index through `PluginIndexValidation`. +12. Save `index.json` durably with an updated `generatedAt` value. +13. Publish through the existing GitHub or signed-server coordinator. +14. Read back the live result and update the local publishing record. +15. Apply any deferred Patreon gate change only after the catalog describes it. + +If a later phase fails after an earlier remote phase succeeded, the CLI reports each completed and incomplete phase. It never converts partial completion into generic success. + +## Interaction and Accessibility + +Default output is concise plain text with no animated spinners, cursor rewriting, decorative tables, color-only distinctions, or unlabeled symbols. Progress is emitted as complete lines suitable for Prism, NVDA, Narrator, and terminal review. + +Human-readable status and warnings go to standard error. Requested data goes to standard output. `--json` writes one final structured result object to standard output while retaining progress on standard error. `--quiet` suppresses nonessential progress but never warnings or errors. + +Interactive mode asks one direct question at a time. Secret values use concealed console input. Automation can provide a secret through standard input using a purpose-specific `--passphrase-stdin` option. Passphrases, access tokens, private keys, and DPAPI plaintext are never accepted as ordinary command-line values and never written to logs. + +Every command has useful `--help` text and examples. Missing required input in noninteractive mode produces an immediate usage error instead of waiting for a prompt. + +## Confirmation and Dry Run + +Read-only commands never prompt. Ordinary local additions and edits may prompt for missing values but do not require redundant confirmation after all values have been displayed. + +Commands that delete metadata, replace an existing release asset, publish a catalog, change a Patreon gate, remove a signing key, break a publish lock, or alter the registry show one exact action summary and require confirmation. `--yes` supplies that confirmation for automation. It does not bypass validation, trust, authentication, or build gates. + +`--dry-run` performs parsing, project resolution, local/live reconciliation, package and catalog validation, and action planning without writing files, committing, uploading, signing, changing gates, or removing locks. + +## Security and Concurrency + +The CLI uses the same implementations as the current manager and AuthorTool for: + +- SHA256 package verification +- ZIP and path-safety checks +- Manifest action allowlisting +- HTTPS enforcement +- Plugin, game, and release identity binding +- Dependency uniqueness validation +- Registry signature and trust-anchor verification +- Catalog claim signing and replay protection +- Publisher-head tracking and reconciliation +- GitHub destination and repository-visibility checks +- SFTP host-key pinning +- Publish locks and compare-before-break behavior +- Patreon gate sequencing + +No `--force`, environment variable, debug mode, or admin mode may weaken these checks. + +A project-level cross-process lock protects `index.json` and author configuration from concurrent GUI and CLI writes. Existing remote publish locks continue to protect server operations. When another process owns a lock, the CLI identifies the lock and exits without changing state. + +## Exit Codes + +- `0`: completed successfully +- `2`: invalid command, missing input, or noninteractive prompt required +- `3`: package, manifest, index, or trust validation failed +- `4`: authentication, authorization, or configuration problem +- `5`: local or remote conflict, publishing failure, or partial completion +- `130`: cancelled by the user or Ctrl+C + +Each failure result contains a stable machine-readable error category in JSON mode in addition to the human explanation. + +## Testing + +Add tests at three levels: + +1. Parser and handler tests verify every command family, required options, project resolution, output routing, secret-input rules, confirmation behavior, JSON shape, and exit code. +2. Workflow tests use disposable projects to compare GUI-facing and CLI-facing operations against the same shared services. They verify equivalent games, dependencies, scripts, manifests, release records, formatted indexes, validation reports, and publish previews. +3. Integration tests use local bare Git repositories, controlled HTTP handlers, fake `git` and `gh` process results, and controlled SFTP boundaries. They verify successful publication, refusal paths, partial completion reporting, lock conflicts, cancellation, and live read-back behavior without touching the user's real repositories or server. + +All existing solution tests must remain green. New tests must cover both the standard build and the `RegistryAdmin=true` build. A disposable end-to-end catalog and package exercise must succeed before local installation. + +## Build and Local Delivery + +Add `installer/build-author-cli.ps1` with framework-dependent and self-contained Windows x64 modes matching the existing AuthorTool build conventions. Produce: + +- `amm-author.exe` +- `amm-author-admin.exe` when built with `RegistryAdmin=true` + +Install the local self-contained build under `C:\Users\buu42\Tools\AccessibilityModManager`. Add that directory to the current user's PATH only if it is not already present. Verify invocation from outside the source tree with `amm-author --version`, `amm-author --help`, and a read-only command against a disposable project. + +The local Git branch, commits, executable, and test artifacts remain on this machine. There will be no GitHub fork, push, pull request, release asset, or redistribution until the user explicitly authorizes it after speaking with RealAmethyst. + +## Non-Goals + +- Replacing or removing the WPF AuthorTool. +- Changing the plugin-index or manifest schema merely for CLI convenience. +- Adding security bypasses or alternative unsigned publishing paths. +- Automating the WPF interface. +- Publishing the local build or source changes. +- Modifying Blind Soldier as part of this work; the CLI is tooling for later releases. diff --git a/installer/build-author-cli.ps1 b/installer/build-author-cli.ps1 new file mode 100644 index 0000000..d97f971 --- /dev/null +++ b/installer/build-author-cli.ps1 @@ -0,0 +1,90 @@ +# Accessibility Mod Manager Author CLI - local build script +# +# Produces a single Windows x64 executable and a matching SHA256 file. +# This script only writes local build artifacts; it never creates or uploads a release. + +[CmdletBinding()] +param( + [string]$Configuration = "Release", + [string]$Version = "", + [switch]$SelfContained, + [switch]$Admin +) + +$ErrorActionPreference = "Stop" +$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$Project = Join-Path $Root "src\AccessibilityModManager.AuthorCli\AccessibilityModManager.AuthorCli.csproj" + +if ([string]::IsNullOrWhiteSpace($Version)) { + $projectXml = [xml](Get-Content -LiteralPath $Project -Raw) + $Version = @($projectXml.Project.PropertyGroup.Version | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -First 1)[0] + if ([string]::IsNullOrWhiteSpace($Version)) { + throw "No -Version was given and no was found in $Project." + } + $Version = $Version.Trim() +} + +$DotnetDirectory = "C:\Program Files\dotnet" +if (Test-Path -LiteralPath $DotnetDirectory) { + $env:PATH = "$DotnetDirectory;$env:PATH" +} + +$variant = if ($Admin) { "admin" } else { "standard" } +$publishName = if ($Admin) { "author-cli-win-x64-admin" } else { "author-cli-win-x64" } +$distName = if ($Admin) { "dist-author-cli-admin" } else { "dist-author-cli" } +$outputName = if ($Admin) { "amm-author-admin.exe" } else { "amm-author.exe" } +$PublishDirectory = Join-Path $Root "publish\$publishName" +$DistDirectory = Join-Path $Root $distName + +function Assert-PathUnderRepository([string]$PathToCheck) { + $fullPath = [IO.Path]::GetFullPath($PathToCheck).TrimEnd('\') + $rootPrefix = $Root.TrimEnd('\') + '\' + if (-not $fullPath.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clean a path outside the repository: $fullPath" + } +} + +Assert-PathUnderRepository $PublishDirectory +if (Test-Path -LiteralPath $PublishDirectory) { + Remove-Item -LiteralPath $PublishDirectory -Recurse -Force +} +New-Item -ItemType Directory -Path $PublishDirectory -Force | Out-Null +New-Item -ItemType Directory -Path $DistDirectory -Force | Out-Null + +$selfContainedValue = if ($SelfContained) { "true" } else { "false" } +$adminValue = if ($Admin) { "true" } else { "false" } + +Write-Host "Building Author CLI $Version ($variant, win-x64, self-contained=$selfContainedValue)..." +& dotnet publish $Project ` + -c $Configuration ` + -r win-x64 ` + --self-contained $selfContainedValue ` + -p:PublishProfile=win-x64 ` + -p:Version=$Version ` + -p:RegistryAdmin=$adminValue ` + -p:SelfContained=$selfContainedValue ` + -o $PublishDirectory + +if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed with exit code $LASTEXITCODE." +} + +$publishedExecutable = Join-Path $PublishDirectory "amm-author.exe" +if (-not (Test-Path -LiteralPath $publishedExecutable -PathType Leaf)) { + throw "Expected published executable was not found: $publishedExecutable" +} + +$distExecutable = Join-Path $DistDirectory $outputName +Copy-Item -LiteralPath $publishedExecutable -Destination $distExecutable -Force + +$hash = (Get-FileHash -LiteralPath $distExecutable -Algorithm SHA256).Hash.ToLowerInvariant() +$hashPath = "$distExecutable.sha256" +$hashLine = "$hash $outputName`r`n" +[IO.File]::WriteAllText($hashPath, $hashLine, (New-Object Text.UTF8Encoding($false))) + +$sizeMiB = [Math]::Round((Get-Item -LiteralPath $distExecutable).Length / 1MB, 1) +Write-Host "Executable: $distExecutable ($sizeMiB MiB)" +Write-Host "SHA256: $hash" +Write-Host "Hash file: $hashPath" diff --git a/src/AccessibilityModManager.AuthorCli/AccessibilityModManager.AuthorCli.csproj b/src/AccessibilityModManager.AuthorCli/AccessibilityModManager.AuthorCli.csproj new file mode 100644 index 0000000..c822ef4 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/AccessibilityModManager.AuthorCli.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + Exe + net10.0-windows + enable + enable + amm-author + 0.28.0 + + + + $(DefineConstants);REGISTRY_ADMIN + + + diff --git a/src/AccessibilityModManager.AuthorCli/CliServices.cs b/src/AccessibilityModManager.AuthorCli/CliServices.cs new file mode 100644 index 0000000..5d71f89 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/CliServices.cs @@ -0,0 +1,219 @@ +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Authoring.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Serilog; + +namespace AccessibilityModManager.AuthorCli; + +public sealed record CliServiceOverrides( + ICliConsole? Console = null, + ILogger? Logger = null, + string? AuthorConfigDirectory = null, + string? LogDirectory = null, + IGitHubService? GitHubService = null, + IPublishedAssetProbe? PublishedAssetProbe = null, + IReleaseWorkflow? ReleaseWorkflow = null, + IIndexWorkflow? IndexWorkflow = null, + ICompleteReleasePublishWorkflow? CompleteReleasePublishWorkflow = null, + IPatreonAuthorSession? PatreonAuthorSession = null, + IPatreonWorkflow? PatreonWorkflow = null, + IServerAuthorTransport? ServerAuthorTransport = null, + IServerWorkflow? ServerWorkflow = null, + ISigningCatalogSource? SigningCatalogSource = null, + ISigningWorkflow? SigningWorkflow = null, + IRegistryAdminWorkflow? RegistryAdminWorkflow = null, + HttpClient? HttpClient = null); + +public static class CliServices +{ + private static readonly string DefaultLogDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "AccessibilityModManager-Author", + "logs"); + + public static ServiceProvider Create(CliServiceOverrides? overrides = null) + { + overrides ??= new CliServiceOverrides(); + var services = new ServiceCollection(); + + services.AddSingleton(_ => + overrides.Logger ?? CreateLogger(overrides.LogDirectory ?? DefaultLogDirectory)); + if (overrides.Console is not null) + { + services.AddSingleton(overrides.Console); + } + else + { + services.AddSingleton(_ => CliConsole.CreateSystem()); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + services.AddSingleton(); + services.AddSingleton(overrides.HttpClient ?? new HttpClient()); + services.AddSingleton(sp => + new AuthorConfigService( + sp.GetRequiredService(), + overrides.AuthorConfigDirectory)); + services.AddSingleton(); + if (overrides.GitHubService is not null) + { + services.AddSingleton(overrides.GitHubService); + } + else + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + if (overrides.PublishedAssetProbe is not null) + { + services.AddSingleton(overrides.PublishedAssetProbe); + } + else + { + services.AddSingleton(); + } + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + if (overrides.SigningCatalogSource is not null) + { + services.AddSingleton(overrides.SigningCatalogSource); + } + else + { + services.AddSingleton(); + services.AddSingleton( + sp => sp.GetRequiredService()); + } + + if (overrides.SigningWorkflow is not null) + { + services.AddSingleton(overrides.SigningWorkflow); + } + else + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + if (overrides.RegistryAdminWorkflow is not null) + { + services.AddSingleton(overrides.RegistryAdminWorkflow); + } + else + { + services.AddSingleton(); + services.AddSingleton( + sp => sp.GetRequiredService()); + } + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + if (overrides.ReleaseWorkflow is not null) + { + services.AddSingleton(overrides.ReleaseWorkflow); + } + else + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + if (overrides.IndexWorkflow is not null) + { + services.AddSingleton(overrides.IndexWorkflow); + } + else + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + if (overrides.CompleteReleasePublishWorkflow is not null) + { + services.AddSingleton(overrides.CompleteReleasePublishWorkflow); + } + else + { + services.AddSingleton(); + services.AddSingleton( + sp => sp.GetRequiredService()); + } + + if (overrides.PatreonAuthorSession is not null) + { + services.AddSingleton(overrides.PatreonAuthorSession); + } + else + { + services.AddSingleton(); + services.AddSingleton( + sp => sp.GetRequiredService()); + } + + if (overrides.PatreonWorkflow is not null) + { + services.AddSingleton(overrides.PatreonWorkflow); + } + else + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + if (overrides.ServerAuthorTransport is not null) + { + services.AddSingleton(overrides.ServerAuthorTransport); + } + else + { + services.AddSingleton(); + services.AddSingleton( + sp => sp.GetRequiredService()); + } + + if (overrides.ServerWorkflow is not null) + { + services.AddSingleton(overrides.ServerWorkflow); + } + else + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + services.AddSingleton(); + + return services.BuildServiceProvider(); + } + + private static ILogger CreateLogger(string logDirectory) + { + ArgumentNullException.ThrowIfNull(logDirectory); + Directory.CreateDirectory(logDirectory); + + return new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.File( + path: Path.Combine(logDirectory, "amm-author-.txt"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 14, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/AuthorCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/AuthorCommands.cs new file mode 100644 index 0000000..1cc91eb --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/AuthorCommands.cs @@ -0,0 +1,82 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Authoring.Workflows; +using AccessibilityModManager.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class AuthorCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var outcomeWriter = services.GetRequiredService(); + var console = services.GetRequiredService(); + var projectContext = services.GetRequiredService(); + var indexFiles = services.GetRequiredService(); + var jsonPayloads = services.GetRequiredService(); + var catalogWorkflow = services.GetRequiredService(); + + var author = new Command("author", "Read or update the author block."); + + var show = new Command("show", "Show the current author block."); + show.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projectContext, parseResult, cancellationToken); + var result = CatalogCommandSupport.Success( + "authorShown", + new + { + projectPath = resolved.ProjectPath, + pluginId = resolved.Index.PluginId, + author = resolved.Index.Author + }, + resolved.Index.Author is null + ? $"Project '{resolved.Index.PluginId}' has no author block." + : $"Loaded the author block for '{resolved.Index.PluginId}'."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var set = new Command("set", "Replace the author block from a camelCase JSON document."); + var inputOption = new Option(CatalogCommandSupport.InputOptionName) + { + Description = "Path to a camelCase JSON file, or - for standard input." + }; + set.Options.Add(inputOption); + set.SetAction(async (parseResult, cancellationToken) => + { + var inputSource = parseResult.GetValue(inputOption); + if (string.IsNullOrWhiteSpace(inputSource)) + { + throw CatalogCommandSupport.Usage( + "author set requires --input ."); + } + + var replacement = await CatalogCommandSupport.ReadInputModelAsync( + jsonPayloads, + console, + inputSource, + cancellationToken); + + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projectContext, + indexFiles, + index => catalogWorkflow.SetAuthor(index, replacement), + "authorUpdated", + "Updated the author block.", + "Dry run: would update the author block.", + cancellationToken); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + author.Subcommands.Add(show); + author.Subcommands.Add(set); + return author; + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/CatalogCommandSupport.cs b/src/AccessibilityModManager.AuthorCli/Commands/CatalogCommandSupport.cs new file mode 100644 index 0000000..65307a6 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/CatalogCommandSupport.cs @@ -0,0 +1,495 @@ +using System.CommandLine; +using System.Text.Json; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Authoring.Workflows; +using AccessibilityModManager.Core.Models; +using AccessibilityModManager.Infrastructure.Services; + +namespace AccessibilityModManager.AuthorCli.Commands; + +internal static class CatalogCommandSupport +{ + public const string InputOptionName = "--input"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public static bool GetJson(ParseResult parseResult) => GetBooleanOption(parseResult, RootCommands.JsonOptionName); + + public static bool GetDryRun(ParseResult parseResult) => GetBooleanOption(parseResult, RootCommands.DryRunOptionName); + + public static bool GetYes(ParseResult parseResult) => GetBooleanOption(parseResult, RootCommands.YesOptionName); + + public static string? GetProjectOption(ParseResult parseResult) => GetOptionValue(parseResult, RootCommands.ProjectOptionName); + + public static int Complete(OutcomeWriter outcomeWriter, ParseResult parseResult, WorkflowResult result) + { + outcomeWriter.Write(result, GetJson(parseResult)); + return (int)CliExitCode.Success; + } + + public static WorkflowResult Success(string status, object? value, params string[] messages) => + new(status, value, messages); + + public static WorkflowException Usage(params string[] messages) => + new(WorkflowErrorKind.Usage, "usage", messages); + + public static WorkflowException Validation(params string[] messages) => + new(WorkflowErrorKind.Validation, "validation", messages); + + public static WorkflowException Authentication(params string[] messages) => + new(WorkflowErrorKind.Authentication, "authentication", messages); + + public static WorkflowException Conflict(params string[] messages) => + new(WorkflowErrorKind.Conflict, "conflict", messages); + + public static async Task ReadInputModelAsync( + JsonPayloadService jsonPayloads, + ICliConsole console, + string source, + CancellationToken cancellationToken) + { + try + { + return await jsonPayloads.ReadAsync(source, console.In, cancellationToken); + } + catch (FileNotFoundException ex) + { + throw Validation(ex.Message); + } + catch (DirectoryNotFoundException ex) + { + throw Validation(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + throw Validation(ex.Message); + } + catch (IOException ex) + { + throw Validation(ex.Message); + } + catch (JsonException ex) + { + throw Validation(ex.Message); + } + catch (InvalidOperationException ex) + { + throw Validation(ex.Message); + } + } + + public static async Task ResolveProjectAsync( + AuthorProjectContext projectContext, + ParseResult parseResult, + CancellationToken cancellationToken) + { + try + { + return await projectContext.ResolveAsync( + GetProjectOption(parseResult), + Environment.CurrentDirectory, + cancellationToken); + } + catch (FileNotFoundException ex) + { + throw Validation(ex.Message); + } + catch (DirectoryNotFoundException ex) + { + throw Validation(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + throw Validation(ex.Message); + } + catch (IOException ex) + { + throw Validation(ex.Message); + } + catch (InvalidOperationException ex) + { + throw Validation(ex.Message); + } + } + + public static async Task> SaveMutationAsync( + ParseResult parseResult, + AuthorProjectContext projectContext, + IndexFileService indexFiles, + Func mutate, + string status, + string successMessage, + string dryRunMessage, + CancellationToken cancellationToken) + { + var resolved = await ResolveProjectAsync(projectContext, parseResult, cancellationToken); + + if (GetDryRun(parseResult)) + { + var candidate = ApplyMutation(mutate, resolved.Index); + ValidateIndexCandidate(candidate); + return CreateCatalogMutationResult(status, resolved.ProjectPath, candidate, dryRun: true, dryRunMessage); + } + + await using var lease = await projectContext.AcquireWriteLeaseAsync(resolved.ProjectPath, cancellationToken); + var current = LoadIndex(indexFiles, resolved.ProjectPath); + var durableCandidate = StampGeneratedAt(ApplyMutation(mutate, current)); + ValidateIndexCandidate(durableCandidate); + indexFiles.Save(resolved.ProjectPath, durableCandidate); + + return CreateCatalogMutationResult(status, resolved.ProjectPath, durableCandidate, dryRun: false, successMessage); + } + + public static WorkflowResult CreateCatalogMutationResult( + string status, + string projectPath, + PluginRepoIndex candidate, + bool dryRun, + string message) => + new( + status, + new + { + projectPath, + pluginId = candidate.PluginId, + repoVersion = candidate.RepoVersion, + generatedAt = candidate.GeneratedAt, + gameCount = candidate.Games.Count, + releaseBucketCount = candidate.ReleasesByGameId.Count, + dryRun, + candidate = dryRun ? candidate : null + }, + new[] { message }); + + public static WorkflowResult CreateProjectSummaryResult( + string status, + string message, + string projectPath, + PluginRepoIndex index, + bool dryRun, + string? gitHubRepo = null, + bool? exists = null) => + new( + status, + new + { + projectPath, + pluginId = index.PluginId, + repoVersion = index.RepoVersion, + generatedAt = index.GeneratedAt, + gameCount = index.Games.Count, + releaseBucketCount = index.ReleasesByGameId.Count, + gitHubRepo, + exists, + dryRun, + candidate = dryRun ? index : null + }, + new[] { message }); + + public static void ValidateIndexCandidate(PluginRepoIndex candidate) + { + try + { + var json = JsonSerializer.Serialize(candidate, JsonOptions); + var report = PluginIndexValidation.Validate(candidate.PluginId, json); + + if (report.PublishBlockers.Count == 0) + { + return; + } + + var messages = new List + { + $"The candidate index for '{candidate.PluginId}' failed validation." + }; + messages.AddRange(report.PublishBlockers); + + throw new WorkflowException( + WorkflowErrorKind.Validation, + "validation", + messages); + } + catch (WorkflowException) + { + throw; + } + catch (JsonException ex) + { + throw Validation(ex.Message); + } + catch (InvalidOperationException ex) + { + throw Validation(ex.Message); + } + } + + public static void RejectMixedInput(string? inputSource, params bool[] fieldFlagsPresent) + { + if (string.IsNullOrWhiteSpace(inputSource)) + { + return; + } + + if (fieldFlagsPresent.Any(x => x)) + { + throw Usage( + "Don't mix --input with individual field flags. Supply either a complete camelCase model with --input or use field flags alone."); + } + } + + public static void EnsureYes(ParseResult parseResult, string explanation) + { + if (!GetYes(parseResult)) + { + throw Usage(explanation); + } + } + + public static LifecycleSlot ParseSlot(string token) => + token switch + { + "pre-install" => LifecycleSlot.PreInstall, + "post-install" => LifecycleSlot.PostInstall, + "post-uninstall" => LifecycleSlot.PostUninstall, + _ => throw Usage( + $"Unknown lifecycle slot '{token}'. Use pre-install, post-install, or post-uninstall.") + }; + + public static GameDefinition FindGame(PluginRepoIndex index, string gameId) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + + var matches = index.Games + .Where(game => string.Equals(game.GameId, gameId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + return matches.Count switch + { + 0 => throw Validation($"Game '{gameId}' was not found."), + 1 => matches[0], + _ => throw Conflict( + $"Multiple games already use id '{gameId}' when compared case-insensitively. Refusing to guess which game to read.") + }; + } + + public static Dependency FindDependency(GameDefinition game, string dependencyId) + { + ArgumentNullException.ThrowIfNull(game); + ArgumentException.ThrowIfNullOrWhiteSpace(dependencyId); + + var matches = game.Dependencies + .Where(dependency => string.Equals(dependency.Id, dependencyId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + return matches.Count switch + { + 0 => throw Validation($"Dependency '{dependencyId}' was not found for game '{game.GameId}'."), + 1 => matches[0], + _ => throw Conflict( + $"Game '{game.GameId}' already contains multiple dependencies with id '{dependencyId}' that differ only by capitalisation. Refusing to guess which dependency to read.") + }; + } + + public static List GetReleasesForGame(PluginRepoIndex index, string gameId) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + + var matchingKeys = index.ReleasesByGameId.Keys + .Where(key => string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + return matchingKeys.Count switch + { + 0 => new List(), + 1 => index.ReleasesByGameId[matchingKeys[0]], + _ => throw Conflict( + $"Multiple release buckets already use game id '{gameId}' when compared case-insensitively. Refusing to guess which release bucket belongs to that game.") + }; + } + + public static void EnsureGitAvailable(bool available, string toolName) + { + if (!available) + { + throw Validation($"{toolName} is not installed or not available on PATH."); + } + } + + public static void EnsureGitHubAuthenticated(bool authenticated) + { + if (!authenticated) + { + throw Authentication( + "You're not signed in to the GitHub CLI yet. Run 'gh auth login' and try again."); + } + } + + public static string NormalizeGitHubRepo(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw Validation("A GitHub repository name is required."); + } + + var trimmed = value.Trim(); + + if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + if (!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + throw Validation($"Only github.com repositories are supported here, not '{uri.Host}'."); + } + + var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length < 2) + { + throw Validation($"'{value}' is not a GitHub repository path."); + } + + return NormalizeGitHubRepo($"{segments[0]}/{segments[1]}"); + } + + var parts = trimmed.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) + { + throw Validation( + $"'{value}' is not a valid GitHub repository. Use 'owner/name' or a GitHub HTTPS URL."); + } + + var owner = parts[0].Trim(); + var name = parts[1].Trim(); + if (name.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + { + name = name[..^4]; + } + + if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(name)) + { + throw Validation( + $"'{value}' is not a valid GitHub repository. Use 'owner/name' or a GitHub HTTPS URL."); + } + + return $"{owner}/{name}"; + } + + public static string DefaultProjectDisplayName(string projectPath) => + Path.GetFileName(projectPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + + public static WorkflowException MapCatalogFailure(InvalidOperationException ex) + { + var kind = IsConflictMessage(ex.Message) + ? WorkflowErrorKind.Conflict + : WorkflowErrorKind.Validation; + + return new WorkflowException( + kind, + kind == WorkflowErrorKind.Conflict ? "conflict" : "validation", + new[] { ex.Message }, + innerException: ex); + } + + public static bool IsSpecified(ParseResult parseResult, System.CommandLine.Option option) => + parseResult.GetResult(option) is not null; + + public static PluginRepoIndex StampGeneratedAt(PluginRepoIndex candidate) => + new() + { + PluginId = candidate.PluginId, + RepoVersion = candidate.RepoVersion, + GeneratedAt = DateTime.UtcNow, + Games = Clone(candidate.Games) ?? new List(), + ReleasesByGameId = Clone(candidate.ReleasesByGameId) ?? new Dictionary>(), + Author = Clone(candidate.Author), + DependencyPresets = Clone(candidate.DependencyPresets) ?? new List() + }; + + private static bool GetBooleanOption(ParseResult parseResult, string optionName) + { + try + { + return parseResult.GetValue(optionName); + } + catch + { + return false; + } + } + + private static T? GetOptionValue(ParseResult parseResult, string optionName) + { + try + { + return parseResult.GetValue(optionName); + } + catch + { + return default; + } + } + + private static PluginRepoIndex LoadIndex(IndexFileService indexFiles, string projectPath) + { + try + { + return indexFiles.Load(projectPath); + } + catch (FileNotFoundException ex) + { + throw Validation(ex.Message); + } + catch (DirectoryNotFoundException ex) + { + throw Validation(ex.Message); + } + catch (InvalidOperationException ex) + { + throw Validation(ex.Message); + } + } + + private static PluginRepoIndex ApplyMutation( + Func mutate, + PluginRepoIndex current) + { + try + { + return mutate(current); + } + catch (WorkflowException) + { + throw; + } + catch (InvalidOperationException ex) + { + throw MapCatalogFailure(ex); + } + } + + private static T? Clone(T? value) + { + if (value is null) + { + return default; + } + + return JsonSerializer.Deserialize( + JsonSerializer.Serialize(value, value.GetType(), JsonOptions), + JsonOptions); + } + + private static bool IsConflictMessage(string message) => + message.Contains("already uses", StringComparison.OrdinalIgnoreCase) || + message.Contains("already contains", StringComparison.OrdinalIgnoreCase) || + message.Contains("release bucket", StringComparison.OrdinalIgnoreCase) || + message.Contains("duplicate dependency id", StringComparison.OrdinalIgnoreCase) || + message.Contains("multiple games", StringComparison.OrdinalIgnoreCase) || + message.Contains("multiple release buckets", StringComparison.OrdinalIgnoreCase) || + message.Contains("capitalisation", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/CommandCatalog.cs b/src/AccessibilityModManager.AuthorCli/Commands/CommandCatalog.cs new file mode 100644 index 0000000..502daaa --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/CommandCatalog.cs @@ -0,0 +1,171 @@ +using System.CommandLine; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class CommandCatalog +{ + public static IReadOnlyList TopLevelNames { get; } = + [ + "project", "author", "game", "dependency", "script", "package", "release", + "index", "github", "patreon", "server", "signing", "registry" + ]; + + public static RootCommand CreateRoot(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var root = RootCommands.Create(); + Command[] groups = + [ + ProjectCommands.Create(services), + AuthorCommands.Create(services), + GameCommands.Create(services), + DependencyCommands.Create(services), + ScriptCommands.Create(services), + PackageCommands.Create(services), + ReleaseCommands.Create(services), + IndexCommands.Create(services), + GitHubCommands.Create(services), + PatreonCommands.Create(services), + ServerCommands.Create(services), + SigningCommands.Create(services), + RegistryCommands.Create(services) + ]; + + foreach (var group in groups) + { + AddExamples(group, group.Name); + root.Subcommands.Add(group); + } + + return root; + } + + private static void AddExamples(Command command, string path) + { + command.Description = $"{(command.Description ?? string.Empty).TrimEnd()}\n\nExample:\n {ExampleFor(path)}"; + foreach (var child in command.Subcommands) + AddExamples(child, $"{path} {child.Name}"); + } + + private static string ExampleFor(string path) => path switch + { + "project" => Help(path), + "project init" => "amm-author project init sample-plugin --project \"C:\\Mods\\Sample\"", + "project recent" => "amm-author project recent", + "project open" => Project(path), + "project clone" => "amm-author project clone owner/sample-plugin --project \"C:\\Mods\\Sample\"", + "project pull" => Project(path), + "project repos" => "amm-author project repos", + "project status" => Project(path), + + "author" => Help(path), + "author show" => Project(path), + "author set" => Input(path, "author.json"), + + "game" => Help(path), + "game list" => Project(path), + "game show" => $"amm-author {path} sample-game --project \"C:\\Mods\\Sample\"", + "game add" => $"amm-author {path} --id sample-game --display-name \"Sample Game\" --project \"C:\\Mods\\Sample\"", + "game update" => $"amm-author {path} sample-game --display-name \"Updated Game\" --project \"C:\\Mods\\Sample\"", + "game remove" => $"amm-author {path} sample-game --project \"C:\\Mods\\Sample\" --yes", + + "dependency" => Help(path), + "dependency list" => CatalogArguments(path, "sample-game"), + "dependency show" => CatalogArguments(path, "sample-game sample-dependency"), + "dependency set" => InputWithArgument(path, "sample-game", "dependency.json"), + "dependency remove" => $"{CatalogArguments(path, "sample-game sample-dependency")} --yes", + "dependency presets" => Project(path), + "dependency apply-preset" => CatalogArguments(path, "sample-game sample-preset"), + + "script" => Help(path), + "script show" => CatalogArguments(path, "sample-game pre-install"), + "script set" => InputWithArgument(path, "sample-game pre-install", "script.json"), + "script clear" => CatalogArguments(path, "sample-game pre-install"), + + "package" => Help(path), + "package build" => $"amm-author {path} --source \"C:\\Mods\\Sample\\Files\" --game sample-game --version 1.0.0 --output \"C:\\Packages\\sample.zip\" --project \"C:\\Mods\\Sample\"", + "package validate" => $"amm-author {path} --zip \"C:\\Packages\\sample.zip\" --plugin sample-plugin --game sample-game --version 1.0.0", + "package hash" => $"amm-author {path} --file \"C:\\Packages\\sample.zip\"", + + "release" => Help(path), + "release list" => CatalogArguments(path, "sample-game"), + "release show" => CatalogArguments(path, "sample-game 1.0.0 stable"), + "release add" => InputWithArgument(path, "sample-game", "release.json"), + "release edit" => InputWithArgument(path, "sample-game 1.0.0 stable", "release.json"), + "release remove" => $"{CatalogArguments(path, "sample-game 1.0.0 stable")} --yes", + "release upload" => ReleaseUpload(path), + "release publish" => $"{ReleaseUpload(path)} --index-message \"Publish sample-game 1.0.0\"", + + "index" => Help(path), + "index show" or "index validate" or "index reconcile" or "index save" or + "index destination" or "index destination get" or "index membership" or + "index lock" or "index lock show" => Project(path), + "index publish" => $"{Project(path)} --message \"Publish catalog update\" --yes", + "index destination set" => $"amm-author {path} github --project \"C:\\Mods\\Sample\"", + "index lock break" => $"amm-author {path} --fingerprint abc123 --project \"C:\\Mods\\Sample\" --yes", + + "github" => Help(path), + "github status" or "github repos" => $"amm-author {path}", + "github releases" => $"amm-author {path} --repo owner/sample-plugin", + + "patreon" => Help(path), + "patreon status" or "patreon login" or "patreon logout" or "patreon tiers" or + "patreon post" => $"amm-author {path}", + "patreon post validate" => $"amm-author {path} --url \"https://www.patreon.com/posts/123456\"", + + "server" => Help(path), + "server status" or "server test" or "server self-test" or + "server release" or "server gate" or "server lock" or "server lock show" => Project(path), + "server clear" => $"amm-author {path} --yes", + "server configure" => $"Get-Content \"C:\\Secrets\\ssh-passphrase.txt\" | amm-author {path} --input \"C:\\Mods\\Sample\\server.json\" --passphrase-stdin", + "server release inspect" => ServerRelease(path, confirmed: false), + "server release upload" => ServerRelease(path, confirmed: true), + "server gate set" => $"amm-author {path} --game sample-game --version 1.0.0 --input \"C:\\Mods\\Sample\\patreon-gate.json\" --project \"C:\\Mods\\Sample\" --yes", + "server gate remove" => $"amm-author {path} --game sample-game --version 1.0.0 --project \"C:\\Mods\\Sample\" --yes", + "server lock break" => $"amm-author {path} --fingerprint abc123 --project \"C:\\Mods\\Sample\" --yes", + + "signing" => Help(path), + "signing status" => $"amm-author {path} --plugin sample-plugin", + "signing create" => SecretPipe(path, "new-key-passphrase.txt", "--plugin sample-plugin --passphrase-stdin"), + "signing export" => SecretPipe(path, "backup-passphrase.txt", "--plugin sample-plugin --destination \"C:\\Secrets\\sample-plugin-key.json\" --passphrase-stdin"), + "signing import" => SecretPipe(path, "backup-passphrase.txt", "--source \"C:\\Secrets\\sample-plugin-key.json\" --passphrase-stdin"), + "signing change-passphrase" => SecretPipe(path, "old-and-new-passphrases.txt", "--plugin sample-plugin --passphrases-stdin"), + "signing claims" or "signing claims preview" or "signing head" => Project(path), + "signing claims sign" or "signing head confirm" or "signing head commit-pending" or + "signing head resume" => $"{Project(path)} --yes", + "signing head status" => $"amm-author {path} --plugin sample-plugin", + + "registry" => AdminHelp(path), + "registry status" => "amm-author-admin registry status", + "registry open" => "amm-author-admin registry open --repo \"C:\\Registry\\PluginRegistry\"", + "registry refresh" => AdminRepo(path), + "registry json" => AdminHelp(path), + "registry json show" or "registry json validate" => $"amm-author-admin {path} --path \"C:\\Registry\\PluginRegistry\\registry.json\"", + "registry json save" => $"amm-author-admin {path} --path \"C:\\Registry\\PluginRegistry\\registry.json\" --input \"C:\\Registry\\candidate.json\"", + "registry sign" => $"Get-Content \"C:\\Secrets\\registry-passphrase.txt\" | amm-author-admin {path} --path \"C:\\Registry\\PluginRegistry\\registry.json\" --private-key \"C:\\Secrets\\registry-key.pem\" --passphrase-stdin", + "registry publish" => $"{AdminRepo(path)} --yes", + "registry commit" => $"{AdminRepo(path)} --message \"Update registry\" --yes", + "registry push" => $"{AdminRepo(path)} --yes", + + _ => Help(path) + }; + + private static string Help(string path) => $"amm-author {path} --help"; + private static string AdminHelp(string path) => $"amm-author-admin {path} --help"; + private static string Project(string path) => $"amm-author {path} --project \"C:\\Mods\\Sample\""; + private static string CatalogArguments(string path, string arguments) => + $"amm-author {path} {arguments} --project \"C:\\Mods\\Sample\""; + private static string Input(string path, string file) => + $"amm-author {path} --input \"C:\\Mods\\Sample\\{file}\" --project \"C:\\Mods\\Sample\""; + private static string InputWithArgument(string path, string argument, string file) => + $"amm-author {path} {argument} --input \"C:\\Mods\\Sample\\{file}\" --project \"C:\\Mods\\Sample\""; + private static string ReleaseUpload(string path) => + $"amm-author {path} --game sample-game --version 1.0.0 --channel stable --repo owner/sample-plugin --zip \"C:\\Packages\\sample.zip\" --project \"C:\\Mods\\Sample\" --yes"; + private static string ServerRelease(string path, bool confirmed) => + $"amm-author {path} --game sample-game --version 1.0.0 --zip \"C:\\Packages\\sample.zip\" --project \"C:\\Mods\\Sample\"{(confirmed ? " --yes" : string.Empty)}"; + private static string SecretPipe(string path, string file, string arguments) => + $"Get-Content \"C:\\Secrets\\{file}\" | amm-author {path} {arguments}"; + private static string AdminRepo(string path) => + $"amm-author-admin {path} --repo \"C:\\Registry\\PluginRegistry\""; +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/DependencyCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/DependencyCommands.cs new file mode 100644 index 0000000..0703bc4 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/DependencyCommands.cs @@ -0,0 +1,273 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Authoring.Workflows; +using AccessibilityModManager.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class DependencyCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var outcomeWriter = services.GetRequiredService(); + var console = services.GetRequiredService(); + var projectContext = services.GetRequiredService(); + var indexFiles = services.GetRequiredService(); + var jsonPayloads = services.GetRequiredService(); + var catalogWorkflow = services.GetRequiredService(); + + var dependency = new Command("dependency", "Read or update dependencies in a game definition."); + + var list = new Command("list", "List dependencies for one game."); + var listGameIdArgument = new Argument("game-id") + { + Description = "Game id." + }; + list.Arguments.Add(listGameIdArgument); + list.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projectContext, parseResult, cancellationToken); + var game = CatalogCommandSupport.FindGame(resolved.Index, parseResult.GetValue(listGameIdArgument)!); + + var result = CatalogCommandSupport.Success( + "dependenciesListed", + new + { + projectPath = resolved.ProjectPath, + pluginId = resolved.Index.PluginId, + gameId = game.GameId, + dependencies = game.Dependencies + }, + $"Loaded {game.Dependencies.Count} dependenc" + (game.Dependencies.Count == 1 ? "y" : "ies") + $" for '{game.GameId}'."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var show = new Command("show", "Show one dependency."); + var showGameIdArgument = new Argument("game-id") + { + Description = "Game id." + }; + var showDependencyIdArgument = new Argument("dependency-id") + { + Description = "Dependency id." + }; + show.Arguments.Add(showGameIdArgument); + show.Arguments.Add(showDependencyIdArgument); + show.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projectContext, parseResult, cancellationToken); + var game = CatalogCommandSupport.FindGame(resolved.Index, parseResult.GetValue(showGameIdArgument)!); + var selectedDependency = CatalogCommandSupport.FindDependency(game, parseResult.GetValue(showDependencyIdArgument)!); + + var result = CatalogCommandSupport.Success( + "dependencyShown", + new + { + projectPath = resolved.ProjectPath, + pluginId = resolved.Index.PluginId, + gameId = game.GameId, + dependency = selectedDependency + }, + $"Loaded dependency '{selectedDependency.Id}' for '{game.GameId}'."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var set = new Command("set", "Add or replace one dependency from a camelCase JSON document."); + var setGameIdArgument = new Argument("game-id") + { + Description = "Game id." + }; + var inputOption = new Option(CatalogCommandSupport.InputOptionName) + { + Description = "Path to a camelCase JSON file, or - for standard input." + }; + set.Arguments.Add(setGameIdArgument); + set.Options.Add(inputOption); + set.SetAction(async (parseResult, cancellationToken) => + { + var inputSource = parseResult.GetValue(inputOption); + if (string.IsNullOrWhiteSpace(inputSource)) + { + throw CatalogCommandSupport.Usage( + "dependency set requires --input ."); + } + + var replacement = await CatalogCommandSupport.ReadInputModelAsync( + jsonPayloads, + console, + inputSource, + cancellationToken); + + var gameId = parseResult.GetValue(setGameIdArgument)!; + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projectContext, + indexFiles, + index => catalogWorkflow.UpsertDependency(index, gameId, replacement), + "dependencySet", + $"Saved dependency '{replacement.Id}' for '{gameId}'.", + $"Dry run: would save dependency '{replacement.Id}' for '{gameId}'.", + cancellationToken); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var remove = new Command("remove", "Remove one dependency."); + var removeGameIdArgument = new Argument("game-id") + { + Description = "Game id." + }; + var removeDependencyIdArgument = new Argument("dependency-id") + { + Description = "Dependency id." + }; + remove.Arguments.Add(removeGameIdArgument); + remove.Arguments.Add(removeDependencyIdArgument); + remove.SetAction(async (parseResult, cancellationToken) => + { + var gameId = parseResult.GetValue(removeGameIdArgument)!; + var dependencyId = parseResult.GetValue(removeDependencyIdArgument)!; + + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projectContext, + indexFiles, + index => catalogWorkflow.RemoveDependency(index, gameId, dependencyId), + "dependencyRemoved", + $"Removed dependency '{dependencyId}' from '{gameId}'.", + $"Dry run: would remove dependency '{dependencyId}' from '{gameId}'.", + cancellationToken); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var presets = new Command("presets", "List built-in dependency presets and any presets stored in the project."); + presets.SetAction(async (parseResult, cancellationToken) => + { + var project = await TryResolveProjectAsync(projectContext, parseResult, cancellationToken); + + var builtInPresets = DependencyPresetCatalog.All + .Select(preset => new + { + source = "builtIn", + id = preset.Id, + displayName = preset.DisplayName, + description = preset.Description, + dependency = preset.ToDependency() + }); + + var projectPresets = project?.Index.DependencyPresets.Select(preset => new + { + source = "project", + id = preset.Id, + displayName = preset.DisplayName, + description = (string?)null, + dependency = preset.Dependency + }) ?? Enumerable.Empty(); + + var result = CatalogCommandSupport.Success( + "dependencyPresetsListed", + new + { + projectPath = project?.ProjectPath, + pluginId = project?.Index.PluginId, + presets = builtInPresets.Cast().Concat(projectPresets).ToArray() + }, + project is null + ? $"Loaded {DependencyPresetCatalog.All.Count} built-in dependency preset(s)." + : $"Loaded {DependencyPresetCatalog.All.Count + project.Index.DependencyPresets.Count} dependency preset(s)."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var applyPreset = new Command("apply-preset", "Clone a preset dependency into a game."); + var applyGameIdArgument = new Argument("game-id") + { + Description = "Game id." + }; + var applyPresetIdArgument = new Argument("preset-id") + { + Description = "Preset id." + }; + applyPreset.Arguments.Add(applyGameIdArgument); + applyPreset.Arguments.Add(applyPresetIdArgument); + applyPreset.SetAction(async (parseResult, cancellationToken) => + { + var gameId = parseResult.GetValue(applyGameIdArgument)!; + var presetId = parseResult.GetValue(applyPresetIdArgument)!; + + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projectContext, + indexFiles, + index => + { + var dependencyModel = ResolvePresetDependency(index, presetId); + return catalogWorkflow.UpsertDependency(index, gameId, dependencyModel); + }, + "dependencyPresetApplied", + $"Applied preset '{presetId}' to '{gameId}'.", + $"Dry run: would apply preset '{presetId}' to '{gameId}'.", + cancellationToken); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + dependency.Subcommands.Add(list); + dependency.Subcommands.Add(show); + dependency.Subcommands.Add(set); + dependency.Subcommands.Add(remove); + dependency.Subcommands.Add(presets); + dependency.Subcommands.Add(applyPreset); + return dependency; + } + + private static Dependency ResolvePresetDependency(PluginRepoIndex index, string presetId) + { + var projectMatches = index.DependencyPresets + .Where(preset => string.Equals(preset.Id, presetId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (projectMatches.Count > 1) + { + throw CatalogCommandSupport.Conflict( + $"Project preset id '{presetId}' is ambiguous because multiple presets match it case-insensitively."); + } + + if (projectMatches.Count == 1) + { + return projectMatches[0].Dependency; + } + + if (DependencyPresetCatalog.TryGet(presetId, out var builtIn)) + { + return builtIn.ToDependency(); + } + + throw CatalogCommandSupport.Validation($"Dependency preset '{presetId}' was not found."); + } + + private static async Task TryResolveProjectAsync( + AuthorProjectContext projectContext, + ParseResult parseResult, + CancellationToken cancellationToken) + { + try + { + return await CatalogCommandSupport.ResolveProjectAsync(projectContext, parseResult, cancellationToken); + } + catch (WorkflowException ex) + when (ex.ErrorKind == WorkflowErrorKind.Validation && + string.IsNullOrWhiteSpace(CatalogCommandSupport.GetProjectOption(parseResult)) && + ex.Messages.Any(message => message.Contains("No author project could be resolved", StringComparison.OrdinalIgnoreCase))) + { + return null; + } + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/GameCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/GameCommands.cs new file mode 100644 index 0000000..5ff2797 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/GameCommands.cs @@ -0,0 +1,461 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Authoring.Workflows; +using AccessibilityModManager.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class GameCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var outcomeWriter = services.GetRequiredService(); + var console = services.GetRequiredService(); + var projectContext = services.GetRequiredService(); + var indexFiles = services.GetRequiredService(); + var jsonPayloads = services.GetRequiredService(); + var catalogWorkflow = services.GetRequiredService(); + + var game = new Command("game", "Read or update games in the project index."); + + var list = new Command("list", "List games in the project."); + list.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projectContext, parseResult, cancellationToken); + var games = resolved.Index.Games + .Select(entry => new + { + id = entry.GameId, + displayName = entry.DisplayName, + modName = entry.ModName, + steamAppId = entry.SteamAppId, + exeName = entry.ExeName, + dependencyCount = entry.Dependencies.Count, + tagCount = entry.Tags.Count, + languageCount = entry.Languages.Count + }) + .ToArray(); + + var result = CatalogCommandSupport.Success( + "gamesListed", + new + { + projectPath = resolved.ProjectPath, + pluginId = resolved.Index.PluginId, + games + }, + $"Found {games.Length} game(s) in '{resolved.Index.PluginId}'."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var show = new Command("show", "Show one game definition."); + var showGameIdArgument = new Argument("game-id") + { + Description = "Game id to show." + }; + show.Arguments.Add(showGameIdArgument); + show.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projectContext, parseResult, cancellationToken); + var selectedGame = CatalogCommandSupport.FindGame(resolved.Index, parseResult.GetValue(showGameIdArgument)!); + + var result = CatalogCommandSupport.Success( + "gameShown", + new + { + projectPath = resolved.ProjectPath, + pluginId = resolved.Index.PluginId, + game = selectedGame + }, + $"Loaded game '{selectedGame.GameId}'."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var add = new Command("add", "Add a new game definition."); + var addInputOption = CreateInputOption(); + var addIdOption = CreateStringOption("--id", "Game id."); + var addDisplayNameOption = CreateStringOption("--display-name", "Game display name."); + var addModNameOption = CreateStringOption("--mod-name", "Displayed mod name."); + var addDescriptionOption = CreateStringOption("--description", "Game description."); + var addSteamAppIdOption = CreateStringOption("--steam-app-id", "Steam app id."); + var addExeNameOption = CreateStringOption("--exe-name", "Primary executable name."); + var addTagOption = CreateMultiStringOption("--tag", "Game tag. Repeat to add more than one."); + var addLanguageOption = CreateMultiStringOption("--language", "Language code. Repeat to add more than one."); + AddFieldOptions(add, addInputOption, addIdOption, addDisplayNameOption, addModNameOption, addDescriptionOption, addSteamAppIdOption, addExeNameOption, addTagOption, addLanguageOption); + add.SetAction(async (parseResult, cancellationToken) => + { + var inputSource = parseResult.GetValue(addInputOption); + var idSpecified = CatalogCommandSupport.IsSpecified(parseResult, addIdOption); + var displayNameSpecified = CatalogCommandSupport.IsSpecified(parseResult, addDisplayNameOption); + var modNameSpecified = CatalogCommandSupport.IsSpecified(parseResult, addModNameOption); + var descriptionSpecified = CatalogCommandSupport.IsSpecified(parseResult, addDescriptionOption); + var steamAppIdSpecified = CatalogCommandSupport.IsSpecified(parseResult, addSteamAppIdOption); + var exeNameSpecified = CatalogCommandSupport.IsSpecified(parseResult, addExeNameOption); + var tagsSpecified = CatalogCommandSupport.IsSpecified(parseResult, addTagOption); + var languagesSpecified = CatalogCommandSupport.IsSpecified(parseResult, addLanguageOption); + + CatalogCommandSupport.RejectMixedInput( + inputSource, + idSpecified, + displayNameSpecified, + modNameSpecified, + descriptionSpecified, + steamAppIdSpecified, + exeNameSpecified, + tagsSpecified, + languagesSpecified); + + GameDefinition newGame; + if (!string.IsNullOrWhiteSpace(inputSource)) + { + newGame = await CatalogCommandSupport.ReadInputModelAsync( + jsonPayloads, + console, + inputSource, + cancellationToken); + } + else + { + newGame = BuildFlagDrivenGame( + parseResult.GetValue(addIdOption), + parseResult.GetValue(addDisplayNameOption), + modNameSpecified ? parseResult.GetValue(addModNameOption) : null, + descriptionSpecified ? parseResult.GetValue(addDescriptionOption) : null, + steamAppIdSpecified ? parseResult.GetValue(addSteamAppIdOption) : null, + exeNameSpecified ? parseResult.GetValue(addExeNameOption) : null, + parseResult.GetValue(addTagOption) ?? Array.Empty(), + parseResult.GetValue(addLanguageOption) ?? Array.Empty(), + idSpecified, + displayNameSpecified); + } + + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projectContext, + indexFiles, + index => catalogWorkflow.AddGame(index, newGame), + "gameAdded", + $"Added game '{newGame.GameId}'.", + $"Dry run: would add game '{newGame.GameId}'.", + cancellationToken); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var update = new Command("update", "Replace or partially update a game definition."); + var currentGameIdArgument = new Argument("current-game-id") + { + Description = "Current game id." + }; + var updateInputOption = CreateInputOption(); + var updateIdOption = CreateStringOption("--id", "New game id."); + var updateDisplayNameOption = CreateStringOption("--display-name", "Game display name."); + var updateModNameOption = CreateStringOption("--mod-name", "Displayed mod name."); + var updateDescriptionOption = CreateStringOption("--description", "Game description."); + var updateSteamAppIdOption = CreateStringOption("--steam-app-id", "Steam app id."); + var updateExeNameOption = CreateStringOption("--exe-name", "Primary executable name."); + var updateTagOption = CreateMultiStringOption("--tag", "Game tag. Repeat to replace the current set."); + var updateLanguageOption = CreateMultiStringOption("--language", "Language code. Repeat to replace the current set."); + var rewriteReleaseGameIdOption = new Option("--rewrite-release-game-id") + { + Description = "Rewrite embedded release GameId values when a rename would otherwise break them." + }; + + update.Arguments.Add(currentGameIdArgument); + AddFieldOptions(update, updateInputOption, updateIdOption, updateDisplayNameOption, updateModNameOption, updateDescriptionOption, updateSteamAppIdOption, updateExeNameOption, updateTagOption, updateLanguageOption); + update.Options.Add(rewriteReleaseGameIdOption); + update.SetAction(async (parseResult, cancellationToken) => + { + var currentGameId = parseResult.GetValue(currentGameIdArgument)!; + var inputSource = parseResult.GetValue(updateInputOption); + + var idSpecified = CatalogCommandSupport.IsSpecified(parseResult, updateIdOption); + var displayNameSpecified = CatalogCommandSupport.IsSpecified(parseResult, updateDisplayNameOption); + var modNameSpecified = CatalogCommandSupport.IsSpecified(parseResult, updateModNameOption); + var descriptionSpecified = CatalogCommandSupport.IsSpecified(parseResult, updateDescriptionOption); + var steamAppIdSpecified = CatalogCommandSupport.IsSpecified(parseResult, updateSteamAppIdOption); + var exeNameSpecified = CatalogCommandSupport.IsSpecified(parseResult, updateExeNameOption); + var tagsSpecified = CatalogCommandSupport.IsSpecified(parseResult, updateTagOption); + var languagesSpecified = CatalogCommandSupport.IsSpecified(parseResult, updateLanguageOption); + + CatalogCommandSupport.RejectMixedInput( + inputSource, + idSpecified, + displayNameSpecified, + modNameSpecified, + descriptionSpecified, + steamAppIdSpecified, + exeNameSpecified, + tagsSpecified, + languagesSpecified); + + var rewriteReleaseGameIds = parseResult.GetValue(rewriteReleaseGameIdOption); + GameDefinition? replacementFromInput = null; + + if (!string.IsNullOrWhiteSpace(inputSource)) + { + replacementFromInput = await CatalogCommandSupport.ReadInputModelAsync( + jsonPayloads, + console, + inputSource, + cancellationToken); + } + else if (!(idSpecified || displayNameSpecified || modNameSpecified || descriptionSpecified || steamAppIdSpecified || exeNameSpecified || tagsSpecified || languagesSpecified)) + { + throw CatalogCommandSupport.Usage( + "game update requires either --input or at least one field flag."); + } + + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projectContext, + indexFiles, + index => + { + var existing = CatalogCommandSupport.FindGame(index, currentGameId); + var replacement = replacementFromInput ?? BuildUpdatedGame( + existing, + parseResult.GetValue(updateIdOption), + parseResult.GetValue(updateDisplayNameOption), + modNameSpecified ? parseResult.GetValue(updateModNameOption) : null, + descriptionSpecified ? parseResult.GetValue(updateDescriptionOption) : null, + steamAppIdSpecified ? parseResult.GetValue(updateSteamAppIdOption) : null, + exeNameSpecified ? parseResult.GetValue(updateExeNameOption) : null, + parseResult.GetValue(updateTagOption) ?? Array.Empty(), + parseResult.GetValue(updateLanguageOption) ?? Array.Empty(), + idSpecified, + displayNameSpecified, + tagsSpecified, + languagesSpecified); + + EnsureRenameRewriteChoice( + parseResult, + index, + currentGameId, + replacement, + rewriteReleaseGameIds); + + return catalogWorkflow.UpdateGame(index, currentGameId, replacement, rewriteReleaseGameIds); + }, + "gameUpdated", + $"Updated game '{currentGameId}'.", + $"Dry run: would update game '{currentGameId}'.", + cancellationToken); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var remove = new Command("remove", "Remove a game and its release bucket."); + var removeGameIdArgument = new Argument("game-id") + { + Description = "Game id to remove." + }; + remove.Arguments.Add(removeGameIdArgument); + remove.SetAction(async (parseResult, cancellationToken) => + { + var gameId = parseResult.GetValue(removeGameIdArgument)!; + CatalogCommandSupport.EnsureYes( + parseResult, + $"game remove is destructive. Re-run with --yes to remove '{gameId}'."); + + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projectContext, + indexFiles, + index => catalogWorkflow.RemoveGame(index, gameId), + "gameRemoved", + $"Removed game '{gameId}'.", + $"Dry run: would remove game '{gameId}'.", + cancellationToken); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + game.Subcommands.Add(list); + game.Subcommands.Add(show); + game.Subcommands.Add(add); + game.Subcommands.Add(update); + game.Subcommands.Add(remove); + return game; + } + + private static Option CreateInputOption() => + new(CatalogCommandSupport.InputOptionName) + { + Description = "Path to a camelCase JSON file, or - for standard input." + }; + + private static Option CreateStringOption(string name, string description) => + new(name) + { + Description = description + }; + + private static Option CreateMultiStringOption(string name, string description) => + new(name) + { + Description = description + }; + + private static void AddFieldOptions( + Command command, + Option inputOption, + Option idOption, + Option displayNameOption, + Option modNameOption, + Option descriptionOption, + Option steamAppIdOption, + Option exeNameOption, + Option tagOption, + Option languageOption) + { + command.Options.Add(inputOption); + command.Options.Add(idOption); + command.Options.Add(displayNameOption); + command.Options.Add(modNameOption); + command.Options.Add(descriptionOption); + command.Options.Add(steamAppIdOption); + command.Options.Add(exeNameOption); + command.Options.Add(tagOption); + command.Options.Add(languageOption); + } + + private static GameDefinition BuildFlagDrivenGame( + string? id, + string? displayName, + string? modName, + string? description, + string? steamAppId, + string? exeName, + IEnumerable tags, + IEnumerable languages, + bool idSpecified, + bool displayNameSpecified) + { + if (!idSpecified || string.IsNullOrWhiteSpace(id)) + { + throw CatalogCommandSupport.Usage("game add requires --id when --input is not used."); + } + + if (!displayNameSpecified || string.IsNullOrWhiteSpace(displayName)) + { + throw CatalogCommandSupport.Usage( + "game add requires --display-name when --input is not used."); + } + + return new GameDefinition + { + GameId = id.Trim(), + DisplayName = displayName.Trim(), + ModName = NormalizeOptionalText(modName), + Description = NormalizeOptionalText(description), + SteamAppId = NormalizeOptionalText(steamAppId), + ExeName = NormalizeOptionalText(exeName), + ProbeRules = new List(), + RegistryProbe = null, + AsciiPathShim = null, + Dependencies = new List(), + Tags = tags.ToList(), + Languages = languages.ToList(), + DefaultPreInstall = null, + DefaultPostInstall = null, + DefaultPostUninstall = null + }; + } + + private static GameDefinition BuildUpdatedGame( + GameDefinition existing, + string? newId, + string? newDisplayName, + string? modName, + string? description, + string? steamAppId, + string? exeName, + IEnumerable tags, + IEnumerable languages, + bool idSpecified, + bool displayNameSpecified, + bool tagsSpecified, + bool languagesSpecified) => + new() + { + GameId = idSpecified + ? RequireNonBlank(newId, "--id") + : existing.GameId, + DisplayName = displayNameSpecified + ? RequireNonBlank(newDisplayName, "--display-name") + : existing.DisplayName, + ModName = modName is not null ? NormalizeOptionalText(modName) : existing.ModName, + Description = description is not null ? NormalizeOptionalText(description) : existing.Description, + SteamAppId = steamAppId is not null ? NormalizeOptionalText(steamAppId) : existing.SteamAppId, + ExeName = exeName is not null ? NormalizeOptionalText(exeName) : existing.ExeName, + ProbeRules = existing.ProbeRules, + RegistryProbe = existing.RegistryProbe, + AsciiPathShim = existing.AsciiPathShim, + Dependencies = existing.Dependencies, + Tags = tagsSpecified ? tags.ToList() : existing.Tags, + Languages = languagesSpecified ? languages.ToList() : existing.Languages, + DefaultPreInstall = existing.DefaultPreInstall, + DefaultPostInstall = existing.DefaultPostInstall, + DefaultPostUninstall = existing.DefaultPostUninstall + }; + + private static void EnsureRenameRewriteChoice( + ParseResult parseResult, + PluginRepoIndex index, + string currentGameId, + GameDefinition replacement, + bool rewriteReleaseGameIds) + { + if (string.Equals(currentGameId, replacement.GameId, StringComparison.Ordinal)) + { + return; + } + + var releases = CatalogCommandSupport.GetReleasesForGame(index, currentGameId); + if (releases.Count == 0) + { + return; + } + + var previewItems = releases + .Take(5) + .Select(release => $"{release.GameId}/{release.Version}") + .ToArray(); + + var preview = string.Join(", ", previewItems); + var remainder = releases.Count > previewItems.Length + ? $" and {releases.Count - previewItems.Length} more" + : string.Empty; + + if (!rewriteReleaseGameIds) + { + throw CatalogCommandSupport.Conflict( + $"Renaming game '{currentGameId}' to '{replacement.GameId}' would rewrite {releases.Count} release(s): {preview}{remainder}. Re-run with --rewrite-release-game-id and --yes to make that durable."); + } + + CatalogCommandSupport.EnsureYes( + parseResult, + $"Renaming game '{currentGameId}' to '{replacement.GameId}' rewrites {releases.Count} release game id value(s). Re-run with --yes to confirm that rewrite."); + } + + private static string RequireNonBlank(string? value, string optionName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw CatalogCommandSupport.Usage($"{optionName} requires a non-blank value."); + } + + return value.Trim(); + } + + private static string? NormalizeOptionalText(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/GitHubCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/GitHubCommands.cs new file mode 100644 index 0000000..a0958bd --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/GitHubCommands.cs @@ -0,0 +1,81 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.AuthorTool.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class GitHubCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var gitHub = services.GetRequiredService(); + var writer = services.GetRequiredService(); + var command = new Command("github", "Inspect GitHub CLI authentication, repositories, and releases."); + + var status = new Command("status", "Check GitHub CLI availability and authentication."); + status.SetAction(async (parseResult, cancellationToken) => + { + var available = await gitHub.IsAvailableAsync(cancellationToken); + var authenticated = available && await gitHub.IsAuthenticatedAsync(cancellationToken); + var result = CatalogCommandSupport.Success( + "githubStatus", + new { available, authenticated }, + available + ? authenticated ? "GitHub CLI is available and authenticated." : "GitHub CLI is available but not authenticated." + : "GitHub CLI is not available."); + return CatalogCommandSupport.Complete(writer, parseResult, result); + }); + + var repos = new Command("repos", "List repositories the signed-in user can push to."); + repos.SetAction(async (parseResult, cancellationToken) => + { + await EnsureReadyAsync(gitHub, cancellationToken); + var values = await gitHub.ListReposAsync(ct: cancellationToken); + return CatalogCommandSupport.Complete( + writer, + parseResult, + CatalogCommandSupport.Success( + "githubReposListed", + new { repositories = values }, + $"Found {values.Count} writable GitHub repository or repositories.")); + }); + + var releases = new Command("releases", "List releases in a GitHub repository."); + var repoOption = new Option("--repo") + { + Description = "GitHub repository in owner/name form.", + Required = true + }; + releases.Options.Add(repoOption); + releases.SetAction(async (parseResult, cancellationToken) => + { + await EnsureReadyAsync(gitHub, cancellationToken); + var repo = CatalogCommandSupport.NormalizeGitHubRepo(parseResult.GetValue(repoOption)!); + var values = await gitHub.ListReleasesAsync(repo, ct: cancellationToken); + return CatalogCommandSupport.Complete( + writer, + parseResult, + CatalogCommandSupport.Success( + "githubReleasesListed", + new { repository = repo, releases = values }, + $"Found {values.Count} release(s) in '{repo}'.")); + }); + + command.Subcommands.Add(status); + command.Subcommands.Add(repos); + command.Subcommands.Add(releases); + return command; + } + + private static async Task EnsureReadyAsync(IGitHubService gitHub, CancellationToken cancellationToken) + { + CatalogCommandSupport.EnsureGitAvailable( + await gitHub.IsAvailableAsync(cancellationToken), + "GitHub CLI (gh)"); + CatalogCommandSupport.EnsureGitHubAuthenticated( + await gitHub.IsAuthenticatedAsync(cancellationToken)); + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/IndexCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/IndexCommands.cs new file mode 100644 index 0000000..e29d9d0 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/IndexCommands.cs @@ -0,0 +1,340 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.Authoring.Workflows; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class IndexCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var writer = services.GetRequiredService(); + var console = services.GetRequiredService(); + var projects = services.GetRequiredService(); + var payloads = services.GetRequiredService(); + var workflows = services.GetRequiredService(); + var config = services.GetRequiredService(); + var registry = services.GetRequiredService(); + + var index = new Command("index", "Inspect, reconcile, save, or publish index.json."); + + var show = new Command("show", "Show the complete current index model."); + show.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + return CatalogCommandSupport.Complete( + writer, + parseResult, + Success( + "indexShown", + new { resolved.ProjectPath, index = resolved.Index }, + $"Loaded index.json for '{resolved.Index.PluginId}'.")); + }); + + var validate = new Command("validate", "Validate index.json exactly as the manager will."); + validate.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var report = workflows.ValidateIndex(resolved.Index); + if (report.PublishBlockers.Count > 0) + { + throw new WorkflowException( + WorkflowErrorKind.Validation, + "indexValidationFailed", + new[] { "The index cannot be published." }.Concat(report.PublishBlockers).ToArray()); + } + + return CatalogCommandSupport.Complete( + writer, + parseResult, + Success( + "indexValid", + new { resolved.ProjectPath, resolved.Index.PluginId, report }, + "The index is valid for publication.")); + }); + + var reconcile = new Command("reconcile", "Compare the local catalog with the verified published catalog."); + reconcile.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + if (CatalogCommandSupport.GetDryRun(parseResult)) + { + var preview = await workflows.ReconcileIndexAsync(resolved.ProjectPath, dryRun: true, cancellationToken); + ThrowIfFailed(preview); + return CatalogCommandSupport.Complete(writer, parseResult, preview); + } + + var result = await workflows.ReconcileIndexAsync( + resolved.ProjectPath, + dryRun: false, + confirmAdoption: CatalogCommandSupport.GetYes(parseResult), + cancellationToken); + ThrowIfFailed(result); + return CatalogCommandSupport.Complete(writer, parseResult, result); + }); + + var save = new Command("save", "Validate and durably save a complete index model."); + var saveInput = new Option(CatalogCommandSupport.InputOptionName) + { + Description = "Path to a complete camelCase PluginRepoIndex JSON document, or - for standard input. Uses the current index when omitted." + }; + save.Options.Add(saveInput); + save.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var source = parseResult.GetValue(saveInput); + var candidate = string.IsNullOrWhiteSpace(source) + ? resolved.Index + : await CatalogCommandSupport.ReadInputModelAsync( + payloads, + console, + source, + cancellationToken); + + candidate = CatalogCommandSupport.StampGeneratedAt(candidate); + if (CatalogCommandSupport.GetDryRun(parseResult)) + { + var preview = await workflows.SaveIndexAsync(resolved.ProjectPath, candidate, dryRun: true, cancellationToken); + ThrowIfFailed(preview); + return CatalogCommandSupport.Complete(writer, parseResult, preview); + } + + var result = await workflows.SaveIndexAsync(resolved.ProjectPath, candidate, dryRun: false, cancellationToken); + ThrowIfFailed(result); + return CatalogCommandSupport.Complete(writer, parseResult, result); + }); + + var destination = new Command("destination", "Read or select the catalog publishing destination."); + var destinationGet = new Command("get", "Show the saved publishing destination."); + destinationGet.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var selected = config.GetPublishDestination(resolved.ProjectPath, resolved.Index.PluginId); + return CatalogCommandSupport.Complete( + writer, + parseResult, + Success( + "indexDestinationShown", + new { resolved.ProjectPath, resolved.Index.PluginId, destination = FormatDestination(selected) }, + selected == PublishDestination.Unset + ? "No publishing destination is selected." + : $"The publishing destination is {FormatDestination(selected)}.")); + }); + + var destinationSet = new Command("set", "Select github, server, or unset for this exact project and plugin id."); + var destinationArgument = new Argument("destination") + { + Description = "github, server, or unset." + }; + destinationSet.Arguments.Add(destinationArgument); + destinationSet.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var selected = ParseDestination(parseResult.GetValue(destinationArgument)!); + if (!CatalogCommandSupport.GetDryRun(parseResult)) + { + await using var lease = await projects.AcquireWriteLeaseAsync(resolved.ProjectPath, cancellationToken); + config.RecordRecent( + resolved.ProjectPath, + CatalogCommandSupport.DefaultProjectDisplayName(resolved.ProjectPath)); + config.SetPublishDestination(resolved.ProjectPath, resolved.Index.PluginId, selected); + } + + return CatalogCommandSupport.Complete( + writer, + parseResult, + Success( + CatalogCommandSupport.GetDryRun(parseResult) + ? "indexDestinationPreviewed" + : "indexDestinationSet", + new + { + resolved.ProjectPath, + resolved.Index.PluginId, + destination = FormatDestination(selected), + dryRun = CatalogCommandSupport.GetDryRun(parseResult) + }, + CatalogCommandSupport.GetDryRun(parseResult) + ? $"The destination would be set to {FormatDestination(selected)}." + : $"Set the destination to {FormatDestination(selected)}.")); + }); + destination.Subcommands.Add(destinationGet); + destination.Subcommands.Add(destinationSet); + + var membership = new Command("membership", "Check this plugin in the signed public registry."); + membership.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var result = await registry.CheckAsync(resolved.Index.PluginId, cancellationToken); + if (!result.RegistryReachable) + { + throw new WorkflowException( + WorkflowErrorKind.Conflict, + "registryUnavailable", + new[] { result.Error ?? "The public registry could not be read." }); + } + if (result.SignatureFailed) + { + throw new WorkflowException( + WorkflowErrorKind.Authentication, + "registrySignatureInvalid", + new[] { result.Error ?? "The public registry signature did not verify." }); + } + + return CatalogCommandSupport.Complete( + writer, + parseResult, + Success( + "registryMembershipChecked", + new + { + resolved.Index.PluginId, + result.IsListed, + result.Entry, + registryUrl = RegistryMembershipChecker.RegistryUrl + }, + result.IsListed + ? $"'{resolved.Index.PluginId}' is listed in the signed public registry." + : $"'{resolved.Index.PluginId}' is not listed in the signed public registry.")); + }); + + var publish = new Command("publish", "Validate and publish index.json to the selected destination."); + var commitMessage = new Option("--message") + { + Description = "Git commit message or server change summary." + }; + publish.Options.Add(commitMessage); + publish.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var selected = config.GetPublishDestination(resolved.ProjectPath, resolved.Index.PluginId); + var request = new IndexPublishRequest( + resolved.ProjectPath, + resolved.Index, + selected, + parseResult.GetValue(commitMessage) ?? "Update accessibility mod index", + CatalogCommandSupport.GetDryRun(parseResult)); + + if (request.DryRun) + { + var preview = await workflows.PreviewIndexPublicationAsync(request, cancellationToken); + ThrowIfFailed(preview); + return CatalogCommandSupport.Complete(writer, parseResult, preview); + } + + if (!CatalogCommandSupport.GetYes(parseResult)) + { + var preview = await workflows.PreviewIndexPublicationAsync(request, cancellationToken); + ThrowIfFailed(preview); + throw new WorkflowException( + WorkflowErrorKind.Conflict, + "confirmationRequired", + new[] { $"Publishing requires --yes after reviewing this destination: {preview.Value!.DestinationDescription}." }); + } + + var result = await workflows.PublishIndexAsync(request, confirmed: true, cancellationToken); + ThrowIfFailed(result); + return CatalogCommandSupport.Complete(writer, parseResult, result); + }); + + var publishLock = new Command("lock", "Inspect or compare-and-break a server publishing lock."); + var lockShow = new Command("show", "Show the current server publishing lock and fingerprint."); + lockShow.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var result = await workflows.InspectIndexLockAsync(resolved.Index.PluginId, cancellationToken); + ThrowIfFailed(result); + return CatalogCommandSupport.Complete(writer, parseResult, result); + }); + + var lockBreak = new Command("break", "Break only the exact server lock fingerprint previously displayed."); + var fingerprint = new Option("--fingerprint") + { + Description = "Exact fingerprint returned by index lock show.", + Required = true + }; + lockBreak.Options.Add(fingerprint); + lockBreak.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var expected = parseResult.GetValue(fingerprint)!; + if (CatalogCommandSupport.GetDryRun(parseResult)) + { + var current = await workflows.InspectIndexLockAsync(resolved.Index.PluginId, cancellationToken); + ThrowIfFailed(current); + if (!current.Value!.Present || + !string.Equals(current.Value.Fingerprint, expected, StringComparison.Ordinal)) + { + throw new WorkflowException( + WorkflowErrorKind.Conflict, + "publishLockChanged", + new[] { "The publish lock does not match the supplied fingerprint, so it would not be removed." }); + } + + return CatalogCommandSupport.Complete( + writer, + parseResult, + Success( + "publishLockBreakPreviewed", + new { resolved.Index.PluginId, fingerprint = expected, dryRun = true }, + "The exact displayed publish lock would be removed.")); + } + + var result = await workflows.BreakIndexLockAsync( + resolved.Index.PluginId, + expected, + CatalogCommandSupport.GetYes(parseResult), + cancellationToken); + ThrowIfFailed(result); + return CatalogCommandSupport.Complete(writer, parseResult, result); + }); + publishLock.Subcommands.Add(lockShow); + publishLock.Subcommands.Add(lockBreak); + + index.Subcommands.Add(show); + index.Subcommands.Add(validate); + index.Subcommands.Add(reconcile); + index.Subcommands.Add(save); + index.Subcommands.Add(destination); + index.Subcommands.Add(membership); + index.Subcommands.Add(publish); + index.Subcommands.Add(publishLock); + return index; + } + + private static PublishDestination ParseDestination(string value) => + value.Trim().ToLowerInvariant() switch + { + "github" => PublishDestination.GitHub, + "server" => PublishDestination.Server, + "unset" or "none" => PublishDestination.Unset, + _ => throw CatalogCommandSupport.Usage("Destination must be github, server, or unset.") + }; + + private static string FormatDestination(PublishDestination destination) => + destination switch + { + PublishDestination.GitHub => "github", + PublishDestination.Server => "server", + _ => "unset" + }; + + private static WorkflowResult Success(string status, object? value, string message) => + new(status, value, new[] { message }); + + private static void ThrowIfFailed(WorkflowResult result) + { + if (result.ErrorKind == WorkflowErrorKind.None) + return; + throw new WorkflowException( + result.ErrorKind, + result.Status, + result.Messages, + result.CompletedPhases); + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/PackageCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/PackageCommands.cs new file mode 100644 index 0000000..95f7b84 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/PackageCommands.cs @@ -0,0 +1,176 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.Authoring.Workflows; +using AccessibilityModManager.AuthorTool.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class PackageCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var outcomeWriter = services.GetRequiredService(); + var console = services.GetRequiredService(); + var projects = services.GetRequiredService(); + var config = services.GetRequiredService(); + var workflows = services.GetRequiredService(); + var hashes = services.GetRequiredService(); + + var package = new Command("package", "Build, validate, or hash wrapped mod packages."); + + var build = new Command("build", "Build a manager-format ZIP from a mod source folder."); + var sourceOption = RequiredStringOption("--source", "Folder containing the mod files to wrap."); + var buildGameOption = RequiredStringOption("--game", "Game id from the project index."); + var buildVersionOption = RequiredStringOption("--version", "Release version written into manifest.json."); + var outputOption = new Option("--output") + { + Description = "Output ZIP path. Defaults to the AuthorTool builds folder." + }; + build.Options.Add(sourceOption); + build.Options.Add(buildGameOption); + build.Options.Add(buildVersionOption); + build.Options.Add(outputOption); + build.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var gameId = parseResult.GetValue(buildGameOption)!; + var version = parseResult.GetValue(buildVersionOption)!; + var source = parseResult.GetValue(sourceOption)!; + var game = CatalogCommandSupport.FindGame(resolved.Index, gameId); + var scriptSources = config.GetGameScriptSources(resolved.ProjectPath, game.GameId); + var scripts = new LifecycleScriptInputs( + game.DefaultPreInstall, + scriptSources?.PreInstall, + game.DefaultPostInstall, + scriptSources?.PostInstall, + game.DefaultPostUninstall, + scriptSources?.PostUninstall); + var output = parseResult.GetValue(outputOption); + if (string.IsNullOrWhiteSpace(output)) + { + output = Path.Combine( + ManifestBuilderService.GetBuildsDirectory(), + $"{game.GameId}-v{version.Trim()}-amm.zip"); + } + + var request = new PackageBuildRequest( + source, + output, + resolved.Index.PluginId, + game.GameId, + version, + game.Dependencies, + scripts); + + if (CatalogCommandSupport.GetDryRun(parseResult)) + { + var preview = workflows.PreviewPackageBuild(request); + return CatalogCommandSupport.Complete( + outcomeWriter, + parseResult, + CatalogCommandSupport.Success( + "packageBuildPreviewed", + new + { + preview.SourceFolder, + preview.OutputZipPath, + preview.PluginId, + preview.GameId, + preview.Version, + preview.TopLevelEntryCount, + preview.HasLifecycleScripts, + dryRun = true + }, + $"Package build is valid and would write '{preview.OutputZipPath}'.")); + } + + var inspection = await workflows.BuildPackageAsync(request, cancellationToken); + return CatalogCommandSupport.Complete( + outcomeWriter, + parseResult, + CatalogCommandSupport.Success( + "packageBuilt", + inspection, + $"Built and validated '{inspection.ZipPath}' ({inspection.FileCount} files, SHA256 {inspection.Sha256}).")); + }); + + var validate = new Command("validate", "Validate a finished package against an expected identity."); + var zipOption = RequiredStringOption("--zip", "Wrapped package ZIP to inspect."); + var pluginOption = RequiredStringOption("--plugin", "Expected plugin id."); + var validateGameOption = RequiredStringOption("--game", "Expected game id."); + var validateVersionOption = RequiredStringOption("--version", "Expected package version."); + validate.Options.Add(zipOption); + validate.Options.Add(pluginOption); + validate.Options.Add(validateGameOption); + validate.Options.Add(validateVersionOption); + validate.SetAction(async (parseResult, cancellationToken) => + { + var inspection = await workflows.ValidatePackageAsync( + parseResult.GetValue(zipOption)!, + parseResult.GetValue(pluginOption)!, + parseResult.GetValue(validateGameOption)!, + parseResult.GetValue(validateVersionOption)!, + cancellationToken); + + if (!inspection.Validation.IsValid) + { + var messages = new[] + { + "Package validation failed because the package contents or identity mismatch the expected release." + } + .Concat(inspection.Validation.Errors) + .ToArray(); + throw CatalogCommandSupport.Validation(messages); + } + + return CatalogCommandSupport.Complete( + outcomeWriter, + parseResult, + CatalogCommandSupport.Success( + "packageValidated", + inspection, + $"Package is valid ({inspection.FileCount} files, SHA256 {inspection.Sha256}).")); + }); + + var hash = new Command("hash", "Compute the lowercase SHA256 of a file."); + var fileOption = RequiredStringOption("--file", "File to hash."); + hash.Options.Add(fileOption); + hash.SetAction(async (parseResult, cancellationToken) => + { + var path = Path.GetFullPath(parseResult.GetValue(fileOption)!); + if (!File.Exists(path)) + throw CatalogCommandSupport.Validation($"File not found: {path}"); + var sha256 = await hashes.ComputeAsync(path, cancellationToken); + + if (!CatalogCommandSupport.GetJson(parseResult)) + { + await console.Out.WriteLineAsync(sha256); + await console.Out.FlushAsync(); + return (int)CliExitCode.Success; + } + + return CatalogCommandSupport.Complete( + outcomeWriter, + parseResult, + CatalogCommandSupport.Success( + "packageHashed", + new { file = path, sha256 }, + $"SHA256 {sha256}")); + }); + + package.Subcommands.Add(build); + package.Subcommands.Add(validate); + package.Subcommands.Add(hash); + return package; + } + + private static Option RequiredStringOption(string name, string description) => + new(name) + { + Description = description, + Required = true + }; +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/PatreonCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/PatreonCommands.cs new file mode 100644 index 0000000..f4b3f4d --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/PatreonCommands.cs @@ -0,0 +1,104 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.Authoring.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class PatreonCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var writer = services.GetRequiredService(); + var workflow = services.GetRequiredService(); + var patreon = new Command("patreon", "Manage the AuthorTool Patreon session and inspect creator posts."); + + var status = new Command("status", "Show whether the author session is signed in."); + status.SetAction(async (parseResult, cancellationToken) => + Complete(writer, parseResult, await workflow.GetStatusAsync(cancellationToken))); + + var login = new Command("login", "Open Patreon's author OAuth sign-in flow."); + login.SetAction(async (parseResult, cancellationToken) => + { + if (CatalogCommandSupport.GetDryRun(parseResult)) + { + var current = await workflow.GetStatusAsync(cancellationToken); + ThrowIfFailed(current); + return CatalogCommandSupport.Complete( + writer, + parseResult, + new WorkflowResult( + "patreonLoginPreviewed", + current.Value, + new[] { "Patreon OAuth would open; no browser or session was changed." })); + } + + return Complete(writer, parseResult, await workflow.SignInAsync(cancellationToken)); + }); + + var logout = new Command("logout", "Revoke and remove the saved Patreon author session."); + logout.SetAction(async (parseResult, cancellationToken) => + { + if (CatalogCommandSupport.GetDryRun(parseResult)) + { + return CatalogCommandSupport.Complete( + writer, + parseResult, + new WorkflowResult( + "patreonLogoutPreviewed", + true, + new[] { "The Patreon author session would be revoked and removed." })); + } + + return Complete(writer, parseResult, await workflow.SignOutAsync(cancellationToken)); + }); + + var tiers = new Command("tiers", "Refresh and list tiers from the signed-in creator campaign."); + tiers.SetAction(async (parseResult, cancellationToken) => + Complete(writer, parseResult, await workflow.GetTiersAsync(cancellationToken))); + + var post = new Command("post", "Inspect creator posts used for gated release attachments."); + var validate = new Command("validate", "Validate a Patreon post URL and list every attachment."); + var url = new Option("--url") + { + Description = "Full Patreon post URL.", + Required = true + }; + validate.Options.Add(url); + validate.SetAction(async (parseResult, cancellationToken) => + Complete( + writer, + parseResult, + await workflow.InspectPostAsync(parseResult.GetValue(url)!, cancellationToken))); + post.Subcommands.Add(validate); + + patreon.Subcommands.Add(status); + patreon.Subcommands.Add(login); + patreon.Subcommands.Add(logout); + patreon.Subcommands.Add(tiers); + patreon.Subcommands.Add(post); + return patreon; + } + + private static int Complete( + OutcomeWriter writer, + ParseResult parseResult, + WorkflowResult result) + { + ThrowIfFailed(result); + return CatalogCommandSupport.Complete(writer, parseResult, result); + } + + private static void ThrowIfFailed(WorkflowResult result) + { + if (result.ErrorKind == WorkflowErrorKind.None) + return; + throw new WorkflowException( + result.ErrorKind, + result.Status, + result.Messages, + result.CompletedPhases); + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/ProjectCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/ProjectCommands.cs new file mode 100644 index 0000000..8860ec5 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/ProjectCommands.cs @@ -0,0 +1,436 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Authoring.Workflows; +using AccessibilityModManager.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class ProjectCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var outcomeWriter = services.GetRequiredService(); + var authorConfig = services.GetRequiredService(); + var projectContext = services.GetRequiredService(); + var indexFiles = services.GetRequiredService(); + var catalogWorkflow = services.GetRequiredService(); + var gitService = services.GetRequiredService(); + var gitHubService = services.GetRequiredService(); + + var project = new Command("project", "Manage local author projects."); + + var init = new Command("init", "Create a starter index.json in the target folder."); + var pluginIdArgument = new Argument("plugin-id") + { + Description = "Plugin id for the new project." + }; + init.Arguments.Add(pluginIdArgument); + init.SetAction(async (parseResult, cancellationToken) => + { + var rawProjectPath = CatalogCommandSupport.GetProjectOption(parseResult); + if (string.IsNullOrWhiteSpace(rawProjectPath)) + { + throw CatalogCommandSupport.Usage( + "project init requires --project to choose the target folder."); + } + + string projectPath; + try + { + projectPath = Path.GetFullPath(rawProjectPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + throw CatalogCommandSupport.Validation(ex.Message); + } + + PluginRepoIndex candidate; + try + { + candidate = catalogWorkflow.CreateProject(parseResult.GetValue(pluginIdArgument)!); + } + catch (InvalidOperationException ex) + { + throw CatalogCommandSupport.Validation(ex.Message); + } + + if (CatalogCommandSupport.GetDryRun(parseResult)) + { + if (indexFiles.Exists(projectPath)) + { + throw CatalogCommandSupport.Conflict( + $"An index.json already exists at '{projectPath}'."); + } + + CatalogCommandSupport.ValidateIndexCandidate(candidate); + + var preview = CatalogCommandSupport.CreateProjectSummaryResult( + "projectInitialized", + $"Dry run: would create a new project for '{candidate.PluginId}' at '{projectPath}'.", + projectPath, + candidate, + dryRun: true); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, preview); + } + + await using var lease = await projectContext.AcquireWriteLeaseAsync(projectPath, cancellationToken); + if (indexFiles.Exists(projectPath)) + { + throw CatalogCommandSupport.Conflict( + $"An index.json already exists at '{projectPath}'."); + } + + var durable = CatalogCommandSupport.StampGeneratedAt(candidate); + CatalogCommandSupport.ValidateIndexCandidate(durable); + indexFiles.Save(projectPath, durable); + + authorConfig.RecordRecent( + projectPath, + displayName: CatalogCommandSupport.DefaultProjectDisplayName(projectPath)); + + var result = CatalogCommandSupport.CreateProjectSummaryResult( + "projectInitialized", + $"Created a new project for '{durable.PluginId}' at '{projectPath}'.", + projectPath, + durable, + dryRun: false); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var recent = new Command("recent", "List recently opened author projects."); + recent.SetAction(parseResult => + { + var projects = authorConfig.Load().RecentProjects + .OrderByDescending(project => project.LastOpenedAt) + .Select(project => new + { + path = project.Path, + displayName = project.DisplayName ?? CatalogCommandSupport.DefaultProjectDisplayName(project.Path), + gitHubRepo = project.GitHubRepo, + lastOpenedAt = project.LastOpenedAt, + exists = Directory.Exists(project.Path), + lastPublishedIndexSha256 = project.LastPublishedIndexSha256 + }) + .ToArray(); + + var result = CatalogCommandSupport.Success( + "recentProjectsListed", + new { projects }, + $"Found {projects.Length} recent project(s)."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var open = new Command("open", "Resolve a project and record it as recent."); + open.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projectContext, parseResult, cancellationToken); + var existing = authorConfig.GetRecent(resolved.ProjectPath); + var dryRun = CatalogCommandSupport.GetDryRun(parseResult); + + if (!dryRun) + { + authorConfig.RecordRecent( + resolved.ProjectPath, + displayName: existing?.DisplayName ?? CatalogCommandSupport.DefaultProjectDisplayName(resolved.ProjectPath), + gitHubRepo: existing?.GitHubRepo); + } + + var refreshed = dryRun ? existing : authorConfig.GetRecent(resolved.ProjectPath); + var result = CatalogCommandSupport.Success( + "projectOpened", + new + { + projectPath = resolved.ProjectPath, + pluginId = resolved.Index.PluginId, + repoVersion = resolved.Index.RepoVersion, + generatedAt = resolved.Index.GeneratedAt, + gameCount = resolved.Index.Games.Count, + releaseBucketCount = resolved.Index.ReleasesByGameId.Count, + gitHubRepo = refreshed?.GitHubRepo, + lastOpenedAt = refreshed?.LastOpenedAt, + dryRun + }, + dryRun + ? $"Dry run: would open '{resolved.Index.PluginId}' at '{resolved.ProjectPath}'." + : $"Opened '{resolved.Index.PluginId}' at '{resolved.ProjectPath}'."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var clone = new Command("clone", "Clone a GitHub repo into a local project folder."); + var repoArgument = new Argument("repo") + { + Description = "GitHub repo as owner/name or GitHub HTTPS URL." + }; + clone.Arguments.Add(repoArgument); + clone.SetAction(async (parseResult, cancellationToken) => + { + var repo = CatalogCommandSupport.NormalizeGitHubRepo(parseResult.GetValue(repoArgument)!); + var rawTargetPath = CatalogCommandSupport.GetProjectOption(parseResult); + + string targetPath; + try + { + targetPath = Path.GetFullPath( + string.IsNullOrWhiteSpace(rawTargetPath) + ? Path.Combine(AuthorConfigService.GetReposDirectory(), repo.Replace('/', '-')) + : rawTargetPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + throw CatalogCommandSupport.Validation(ex.Message); + } + + if (CatalogCommandSupport.GetDryRun(parseResult)) + { + var preview = CatalogCommandSupport.Success( + "projectCloned", + new + { + projectPath = targetPath, + gitHubRepo = repo, + hasIndex = (bool?)null, + updatedExisting = (bool?)null, + dryRun = true + }, + $"Dry run: would clone or update '{repo}' in '{targetPath}' and record it as a recent project."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, preview); + } + + CatalogCommandSupport.EnsureGitAvailable(await gitService.IsAvailableAsync(cancellationToken), "Git"); + + var updatedExisting = false; + if (Directory.Exists(targetPath)) + { + if (await gitService.IsRepoAsync(targetPath, cancellationToken)) + { + var pullResult = await gitService.PullAsync(targetPath, cancellationToken); + if (!pullResult.Success) + { + throw CatalogCommandSupport.Validation( + $"git pull failed for '{repo}': {pullResult.Combined}"); + } + + updatedExisting = true; + } + else if (Directory.EnumerateFileSystemEntries(targetPath).Any()) + { + throw CatalogCommandSupport.Conflict( + $"Target folder '{targetPath}' already exists and is not a Git repository."); + } + else + { + var cloneResult = await gitService.CloneAsync( + $"https://github.com/{repo}.git", + targetPath, + cancellationToken); + + if (!cloneResult.Success) + { + throw CatalogCommandSupport.Validation( + $"git clone failed for '{repo}': {cloneResult.Combined}"); + } + } + } + else + { + var cloneResult = await gitService.CloneAsync( + $"https://github.com/{repo}.git", + targetPath, + cancellationToken); + + if (!cloneResult.Success) + { + throw CatalogCommandSupport.Validation( + $"git clone failed for '{repo}': {cloneResult.Combined}"); + } + } + + var hasIndex = indexFiles.Exists(targetPath); + authorConfig.RecordRecent(targetPath, displayName: repo, gitHubRepo: repo); + + var result = CatalogCommandSupport.Success( + "projectCloned", + new + { + projectPath = targetPath, + gitHubRepo = repo, + hasIndex, + updatedExisting, + dryRun = false + }, + hasIndex + ? updatedExisting + ? $"Updated '{repo}' in '{targetPath}' and recorded it as a recent project." + : $"Cloned '{repo}' to '{targetPath}' and recorded it as a recent project." + : updatedExisting + ? $"Updated '{repo}' in '{targetPath}' and recorded it as a recent project. No index.json exists there yet; run project init in that folder if this repo should host a catalog." + : $"Cloned '{repo}' to '{targetPath}' and recorded it as a recent project. No index.json exists there yet; run project init in that folder if this repo should host a catalog."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var pull = new Command("pull", "Run git pull --ff-only in the resolved project folder."); + pull.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projectContext, parseResult, cancellationToken); + + if (CatalogCommandSupport.GetDryRun(parseResult)) + { + var preview = CatalogCommandSupport.Success( + "projectPulled", + new + { + projectPath = resolved.ProjectPath, + pluginId = resolved.Index.PluginId, + currentBranch = (string?)null, + remoteUrl = (string?)null, + output = (string?)null, + dryRun = true + }, + $"Dry run: would pull the latest commits into '{resolved.ProjectPath}'."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, preview); + } + + CatalogCommandSupport.EnsureGitAvailable(await gitService.IsAvailableAsync(cancellationToken), "Git"); + await using var lease = await projectContext.AcquireWriteLeaseAsync(resolved.ProjectPath, cancellationToken); + if (!await gitService.IsRepoAsync(resolved.ProjectPath, cancellationToken)) + { + throw CatalogCommandSupport.Validation( + $"'{resolved.ProjectPath}' is not a Git repository."); + } + + var pullResult = await gitService.PullAsync(resolved.ProjectPath, cancellationToken); + if (!pullResult.Success) + { + throw CatalogCommandSupport.Validation( + $"git pull failed in '{resolved.ProjectPath}': {pullResult.Combined}"); + } + + var currentBranch = await gitService.GetCurrentBranchAsync(resolved.ProjectPath, cancellationToken); + var remoteUrl = await gitService.GetRemoteUrlAsync(resolved.ProjectPath, ct: cancellationToken); + var result = CatalogCommandSupport.Success( + "projectPulled", + new + { + projectPath = resolved.ProjectPath, + pluginId = resolved.Index.PluginId, + currentBranch, + remoteUrl, + output = string.IsNullOrWhiteSpace(pullResult.Combined) ? null : pullResult.Combined, + dryRun = false + }, + $"Pulled the latest commits into '{resolved.ProjectPath}'."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var repos = new Command("repos", "List GitHub repos the current gh account can push to."); + repos.SetAction(async (parseResult, cancellationToken) => + { + CatalogCommandSupport.EnsureGitAvailable( + await gitHubService.IsAvailableAsync(cancellationToken), + "GitHub CLI ('gh')"); + CatalogCommandSupport.EnsureGitHubAuthenticated( + await gitHubService.IsAuthenticatedAsync(cancellationToken)); + + var availableRepos = await gitHubService.ListReposAsync(ct: cancellationToken); + var result = CatalogCommandSupport.Success( + "projectReposListed", + new + { + repos = availableRepos.Select(repo => new + { + nameWithOwner = repo.NameWithOwner, + description = repo.Description, + url = repo.Url + }).ToArray() + }, + $"Found {availableRepos.Count} GitHub repo(s)."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var status = new Command("status", "Show the resolved project and local repo status."); + status.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projectContext, parseResult, cancellationToken); + var recentProject = authorConfig.GetRecent(resolved.ProjectPath); + + var gitAvailable = await gitService.IsAvailableAsync(cancellationToken); + var isRepository = gitAvailable && await gitService.IsRepoAsync(resolved.ProjectPath, cancellationToken); + + string? currentBranch = null; + string? remoteUrl = null; + bool? hasUncommittedChanges = null; + string[]? statusPorcelain = null; + string? statusError = null; + + if (isRepository) + { + currentBranch = await gitService.GetCurrentBranchAsync(resolved.ProjectPath, cancellationToken); + remoteUrl = await gitService.GetRemoteUrlAsync(resolved.ProjectPath, ct: cancellationToken); + + var statusResult = await gitService.StatusPorcelainAsync(resolved.ProjectPath, cancellationToken); + if (statusResult.Success) + { + hasUncommittedChanges = !string.IsNullOrWhiteSpace(statusResult.Stdout); + statusPorcelain = string.IsNullOrWhiteSpace(statusResult.Stdout) + ? Array.Empty() + : statusResult.Stdout.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + } + else + { + statusError = statusResult.Combined; + } + } + + var result = CatalogCommandSupport.Success( + "projectStatus", + new + { + projectPath = resolved.ProjectPath, + pluginId = resolved.Index.PluginId, + repoVersion = resolved.Index.RepoVersion, + generatedAt = resolved.Index.GeneratedAt, + gameCount = resolved.Index.Games.Count, + releaseBucketCount = resolved.Index.ReleasesByGameId.Count, + exists = Directory.Exists(resolved.ProjectPath), + gitHubRepo = recentProject?.GitHubRepo, + lastOpenedAt = recentProject?.LastOpenedAt, + git = new + { + available = gitAvailable, + isRepository, + currentBranch, + remoteUrl, + hasUncommittedChanges, + statusPorcelain, + statusError + } + }, + $"Resolved '{resolved.Index.PluginId}' at '{resolved.ProjectPath}'."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + project.Subcommands.Add(init); + project.Subcommands.Add(recent); + project.Subcommands.Add(open); + project.Subcommands.Add(clone); + project.Subcommands.Add(pull); + project.Subcommands.Add(repos); + project.Subcommands.Add(status); + + return project; + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/RegistryCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/RegistryCommands.cs new file mode 100644 index 0000000..54699f7 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/RegistryCommands.cs @@ -0,0 +1,189 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.Authoring.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class RegistryCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var writer = services.GetRequiredService(); + var console = services.GetRequiredService(); + var workflow = services.GetRequiredService(); + var registry = new Command( + "registry", + "Maintain the signed global plugin registry (admin build required for every operation)."); + + var status = new Command("status", "Show registry-admin build and checkout status."); + status.SetAction(parseResult => Complete(writer, parseResult, workflow.GetStatus())); + + var open = new Command("open", "Open an existing registry checkout or clone the canonical repository."); + var openRepo = Optional("--repo", "Checkout path; defaults inside the author configuration directory."); + open.Options.Add(openRepo); + open.SetAction(async (parseResult, cancellationToken) => Complete( + writer, parseResult, + await workflow.OpenAsync(parseResult.GetValue(openRepo), cancellationToken))); + + var refresh = new Command("refresh", "Fast-forward the registry checkout and validate its JSON."); + var refreshRepo = Required("--repo", "Registry checkout path."); + refresh.Options.Add(refreshRepo); + refresh.SetAction(async (parseResult, cancellationToken) => Complete( + writer, parseResult, + await workflow.RefreshAsync(parseResult.GetValue(refreshRepo)!, cancellationToken))); + + var json = new Command("json", "Read, validate, or save the registry JSON document."); + var show = new Command("show", "Return the exact JSON, path, and SHA256."); + var showPath = Required("--path", "Registry JSON path or checkout directory."); + show.Options.Add(showPath); + show.SetAction(parseResult => Complete( + writer, parseResult, workflow.ShowJson(parseResult.GetValue(showPath)!))); + + var validate = new Command("validate", "Run the exact manager-side registry validation rules."); + var validatePath = Required("--path", "Registry JSON path."); + validate.Options.Add(validatePath); + validate.SetAction(parseResult => Complete( + writer, parseResult, workflow.Validate(parseResult.GetValue(validatePath)!))); + + var save = new Command("save", "Parse and durably replace the registry JSON without a UTF-8 BOM."); + var savePath = Required("--path", "Registry JSON path."); + var saveInput = Required(CatalogCommandSupport.InputOptionName, "JSON file path, or '-' for redirected input."); + save.Options.Add(savePath); + save.Options.Add(saveInput); + save.SetAction(async (parseResult, cancellationToken) => + { + EnsureAdmin(workflow); + var content = await ReadInputAsync( + parseResult.GetValue(saveInput)!, console, cancellationToken); + return Complete(writer, parseResult, + workflow.Save(parseResult.GetValue(savePath)!, content)); + }); + json.Subcommands.Add(show); + json.Subcommands.Add(validate); + json.Subcommands.Add(save); + + var sign = new Command("sign", "Sign the validated JSON with the offline registry private key."); + var signPath = Required("--path", "Registry JSON path."); + var privateKey = Required("--private-key", "Encrypted registry private-key PEM path."); + var passphraseStdin = new Option("--passphrase-stdin") + { + Description = "Read the private-key passphrase from one redirected input line." + }; + sign.Options.Add(signPath); + sign.Options.Add(privateKey); + sign.Options.Add(passphraseStdin); + sign.SetAction(async (parseResult, cancellationToken) => + { + EnsureAdmin(workflow); + string passphrase; + if (parseResult.GetValue(passphraseStdin)) + { + if (!console.IsInputRedirected) + throw CatalogCommandSupport.Usage("--passphrase-stdin requires redirected standard input."); + passphrase = await SecretReader.ReadAsync(console, cancellationToken); + } + else + { + if (console.IsInputRedirected) + throw CatalogCommandSupport.Usage( + "Redirected secret input requires --passphrase-stdin; never put passphrases on the command line."); + console.WriteStatus("Registry private-key passphrase:"); + passphrase = await SecretReader.ReadAsync(console, cancellationToken); + } + + return Complete(writer, parseResult, workflow.Sign( + parseResult.GetValue(signPath)!, + parseResult.GetValue(privateKey)!, + passphrase, + CatalogCommandSupport.GetYes(parseResult))); + }); + + var publish = new Command("publish", "Atomically publish and read back the signed registry pair."); + var publishRepo = Required("--repo", "Registry checkout path."); + publish.Options.Add(publishRepo); + publish.SetAction(async (parseResult, cancellationToken) => Complete( + writer, parseResult, + await workflow.PublishAsync( + parseResult.GetValue(publishRepo)!, + CatalogCommandSupport.GetYes(parseResult), + cancellationToken))); + + var commit = new Command("commit", "Stage and commit registry JSON and signature changes locally."); + var commitRepo = Required("--repo", "Registry checkout path."); + var message = new Option("--message") + { + Description = "Commit message; defaults to 'Update plugin registry'." + }; + commit.Options.Add(commitRepo); + commit.Options.Add(message); + commit.SetAction(async (parseResult, cancellationToken) => + { + EnsureAdmin(workflow); + RequireYes(parseResult, "Committing registry changes"); + return Complete(writer, parseResult, await workflow.CommitAsync( + parseResult.GetValue(commitRepo)!, + parseResult.GetValue(message) ?? "Update plugin registry", + cancellationToken)); + }); + + var push = new Command("push", "Push committed registry history; this does not publish the live registry."); + var pushRepo = Required("--repo", "Registry checkout path."); + push.Options.Add(pushRepo); + push.SetAction(async (parseResult, cancellationToken) => + { + EnsureAdmin(workflow); + RequireYes(parseResult, "Pushing registry history"); + return Complete(writer, parseResult, + await workflow.PushAsync(parseResult.GetValue(pushRepo)!, cancellationToken)); + }); + + registry.Subcommands.Add(status); + registry.Subcommands.Add(open); + registry.Subcommands.Add(refresh); + registry.Subcommands.Add(json); + registry.Subcommands.Add(sign); + registry.Subcommands.Add(publish); + registry.Subcommands.Add(commit); + registry.Subcommands.Add(push); + return registry; + } + + private static Option Required(string name, string description) => + new(name) { Description = description, Required = true }; + + private static Option Optional(string name, string description) => + new(name) { Description = description }; + + private static async Task ReadInputAsync( + string source, + ICliConsole console, + CancellationToken ct) + { + if (source == "-") + return await console.In.ReadToEndAsync().WaitAsync(ct); + return await File.ReadAllTextAsync(Path.GetFullPath(source), ct); + } + + private static void EnsureAdmin(IRegistryAdminWorkflow workflow) + { + var status = workflow.GetStatus(); + if (status.ErrorKind != WorkflowErrorKind.None) + throw new WorkflowException(status.ErrorKind, status.Status, status.Messages, status.CompletedPhases); + } + + private static void RequireYes(ParseResult parseResult, string operation) + { + if (!CatalogCommandSupport.GetYes(parseResult)) + throw CatalogCommandSupport.Conflict($"{operation} requires --yes."); + } + + private static int Complete(OutcomeWriter writer, ParseResult parseResult, WorkflowResult result) + { + if (result.ErrorKind != WorkflowErrorKind.None) + throw new WorkflowException(result.ErrorKind, result.Status, result.Messages, result.CompletedPhases); + return CatalogCommandSupport.Complete(writer, parseResult, result); + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/ReleaseCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/ReleaseCommands.cs new file mode 100644 index 0000000..bf1213a --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/ReleaseCommands.cs @@ -0,0 +1,387 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.Authoring.Workflows; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class ReleaseCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var writer = services.GetRequiredService(); + var console = services.GetRequiredService(); + var projects = services.GetRequiredService(); + var indexFiles = services.GetRequiredService(); + var payloads = services.GetRequiredService(); + var catalog = services.GetRequiredService(); + var workflows = services.GetRequiredService(); + var config = services.GetRequiredService(); + + var release = new Command("release", "Read, edit, upload, or publish mod releases."); + + var list = new Command("list", "List releases for one game."); + var listGame = GameArgument(); + list.Arguments.Add(listGame); + list.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var gameId = parseResult.GetValue(listGame)!; + CatalogCommandSupport.FindGame(resolved.Index, gameId); + var values = CatalogCommandSupport.GetReleasesForGame(resolved.Index, gameId); + return CatalogCommandSupport.Complete( + writer, + parseResult, + CatalogCommandSupport.Success( + "releasesListed", + new { resolved.ProjectPath, resolved.Index.PluginId, gameId, releases = values }, + $"Found {values.Count} release(s) for '{gameId}'.")); + }); + + var show = new Command("show", "Show a release by version and channel."); + var showGame = GameArgument(); + var showVersion = VersionArgument(); + var showChannel = ChannelArgument(); + show.Arguments.Add(showGame); + show.Arguments.Add(showVersion); + show.Arguments.Add(showChannel); + show.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var selected = FindRelease( + resolved.Index, + parseResult.GetValue(showGame)!, + parseResult.GetValue(showVersion)!, + parseResult.GetValue(showChannel)!); + return CatalogCommandSupport.Complete( + writer, + parseResult, + CatalogCommandSupport.Success("releaseShown", new { release = selected }, $"Loaded release {selected.Version} ({selected.Channel}).")); + }); + + var add = new Command("add", "Add or replace a release record from camelCase JSON."); + var addGame = GameArgument(); + var addInput = RequiredInputOption(); + add.Arguments.Add(addGame); + add.Options.Add(addInput); + add.SetAction(async (parseResult, cancellationToken) => + { + var model = await CatalogCommandSupport.ReadInputModelAsync( + payloads, + console, + parseResult.GetValue(addInput)!, + cancellationToken); + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projects, + indexFiles, + index => catalog.AddRelease(index, parseResult.GetValue(addGame)!, model), + "releaseAdded", + $"Saved release {model.Version} ({model.Channel}).", + $"Release {model.Version} ({model.Channel}) is valid and would be saved.", + cancellationToken); + return CatalogCommandSupport.Complete(writer, parseResult, result); + }); + + var edit = new Command("edit", "Replace a release record while tracking its original identity."); + var editGame = GameArgument(); + var editVersion = VersionArgument("current-version"); + var editChannel = ChannelArgument("current-channel"); + var editInput = RequiredInputOption(); + edit.Arguments.Add(editGame); + edit.Arguments.Add(editVersion); + edit.Arguments.Add(editChannel); + edit.Options.Add(editInput); + edit.SetAction(async (parseResult, cancellationToken) => + { + var model = await CatalogCommandSupport.ReadInputModelAsync( + payloads, + console, + parseResult.GetValue(editInput)!, + cancellationToken); + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projects, + indexFiles, + index => catalog.EditRelease( + index, + parseResult.GetValue(editGame)!, + parseResult.GetValue(editVersion)!, + parseResult.GetValue(editChannel)!, + model), + "releaseEdited", + $"Updated release to {model.Version} ({model.Channel}).", + $"Release edit to {model.Version} ({model.Channel}) is valid and would be saved.", + cancellationToken); + return CatalogCommandSupport.Complete(writer, parseResult, result); + }); + + var remove = new Command("remove", "Remove a release record."); + var removeGame = GameArgument(); + var removeVersion = VersionArgument(); + var removeChannel = ChannelArgument(); + remove.Arguments.Add(removeGame); + remove.Arguments.Add(removeVersion); + remove.Arguments.Add(removeChannel); + remove.SetAction(async (parseResult, cancellationToken) => + { + CatalogCommandSupport.EnsureYes( + parseResult, + "Removing a release requires --yes after reviewing the game, version, and channel."); + var gameId = parseResult.GetValue(removeGame)!; + var version = parseResult.GetValue(removeVersion)!; + var channel = parseResult.GetValue(removeChannel)!; + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projects, + indexFiles, + index => catalog.RemoveRelease(index, gameId, version, channel), + "releaseRemoved", + $"Removed release {version} ({channel}) from '{gameId}'.", + $"Release {version} ({channel}) would be removed from '{gameId}'.", + cancellationToken); + return CatalogCommandSupport.Complete(writer, parseResult, result); + }); + + var upload = CreateUploadCommand("upload", "Validate and upload a package without changing index.json."); + upload.SetAction(async (parseResult, cancellationToken) => + { + var request = await BuildPublishRequestAsync( + parseResult, + projects, + config, + payloads, + console, + ReleaseAssetDestination.GitHub, + patreonGateSource: null, + cancellationToken); + if (CatalogCommandSupport.GetDryRun(parseResult)) + { + var previewResult = await workflows.PreviewReleaseAsync(request, cancellationToken); + ThrowIfFailed(previewResult); + return CatalogCommandSupport.Complete(writer, parseResult, previewResult); + } + + var preparedResult = await workflows.PrepareReleaseAsync(request, cancellationToken); + ThrowIfFailed(preparedResult); + await using var prepared = preparedResult.Value!; + var published = await workflows.PublishReleaseAsync( + prepared, + request, + CatalogCommandSupport.GetYes(parseResult), + cancellationToken); + ThrowIfFailed(published); + config.SetGameSourceRepo(request.ProjectPath, request.GameId, request.SourceRepo); + return CatalogCommandSupport.Complete(writer, parseResult, published); + }); + + var publish = CreateUploadCommand( + "publish", + "Run the complete upload, catalog-save, and index-publication transaction."); + var indexMessage = new Option("--index-message") + { + Description = "Git commit message or server change summary for index publication." + }; + publish.Options.Add(indexMessage); + var assetDestination = new Option("--asset-destination") + { + Description = "Package destination: github (default), server, or patreon-post." + }; + var patreonGate = new Option("--patreon-gate") + { + Description = "Path to a complete camelCase PatreonGate JSON document, or - for standard input." + }; + var patreonAttachment = new Option("--patreon-attachment") + { + Description = "Stable attachment selection id returned by 'patreon post validate'." + }; + publish.Options.Add(assetDestination); + publish.Options.Add(patreonGate); + publish.Options.Add(patreonAttachment); + publish.SetAction(async (parseResult, cancellationToken) => + { + var selectedAssetDestination = ParseAssetDestination(parseResult.GetValue(assetDestination)); + var gateSource = parseResult.GetValue(patreonGate); + var request = await BuildPublishRequestAsync( + parseResult, + projects, + config, + payloads, + console, + selectedAssetDestination, + gateSource, + cancellationToken); + var destination = config.GetPublishDestination(request.ProjectPath, request.PluginId); + var completeRequest = new CompleteReleasePublishRequest( + request, + destination, + parseResult.GetValue(indexMessage) ?? $"Publish {request.GameId} {request.Version}", + CatalogCommandSupport.GetDryRun(parseResult), + selectedAssetDestination, + parseResult.GetValue(patreonAttachment)); + + if (completeRequest.DryRun) + { + var preview = await workflows.PreviewCompleteReleaseAsync(completeRequest, cancellationToken); + ThrowIfFailed(preview); + return CatalogCommandSupport.Complete(writer, parseResult, preview); + } + + if (!CatalogCommandSupport.GetYes(parseResult)) + { + var preview = await workflows.PreviewCompleteReleaseAsync(completeRequest, cancellationToken); + ThrowIfFailed(preview); + throw new WorkflowException( + WorkflowErrorKind.Conflict, + "confirmationRequired", + new[] + { + $"Complete publication requires --yes after reviewing package destination {preview.Value!.Release.DestinationDescription} and catalog destination {preview.Value.Index.DestinationDescription}." + }); + } + + var result = await workflows.PublishCompleteReleaseAsync( + completeRequest, + confirmed: true, + cancellationToken); + ThrowIfFailed(result); + if (selectedAssetDestination == ReleaseAssetDestination.GitHub) + config.SetGameSourceRepo(request.ProjectPath, request.GameId, request.SourceRepo); + return CatalogCommandSupport.Complete(writer, parseResult, result); + }); + + release.Subcommands.Add(list); + release.Subcommands.Add(show); + release.Subcommands.Add(add); + release.Subcommands.Add(edit); + release.Subcommands.Add(remove); + release.Subcommands.Add(upload); + release.Subcommands.Add(publish); + return release; + } + + private static Command CreateUploadCommand(string name, string description) + { + var command = new Command(name, description); + command.Options.Add(RequiredOption("--game", "Game id from the project index.")); + command.Options.Add(RequiredOption("--version", "Release version.")); + command.Options.Add(RequiredOption("--channel", "Release channel, such as stable or beta.")); + command.Options.Add(new Option("--repo") { Description = "GitHub repository in owner/name form. Uses the saved per-game repository when omitted." }); + command.Options.Add(RequiredOption("--zip", "Wrapped package ZIP to upload.")); + command.Options.Add(new Option("--asset-name") { Description = "Published asset filename. Defaults to the ZIP's filename." }); + command.Options.Add(new Option("--notes") { Description = "Release notes." }); + command.Options.Add(new Option("--changelog-url") { Description = "HTTPS changelog URL." }); + return command; + } + + private static async Task BuildPublishRequestAsync( + ParseResult parseResult, + AuthorProjectContext projects, + AuthorConfigService config, + JsonPayloadService payloads, + ICliConsole console, + ReleaseAssetDestination assetDestination, + string? patreonGateSource, + CancellationToken cancellationToken) + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var gameId = parseResult.GetValue("--game")!; + var game = CatalogCommandSupport.FindGame(resolved.Index, gameId); + var repo = parseResult.GetValue("--repo") + ?? config.GetGameSourceRepo(resolved.ProjectPath, game.GameId); + if (assetDestination == ReleaseAssetDestination.GitHub && string.IsNullOrWhiteSpace(repo)) + { + throw CatalogCommandSupport.Validation( + $"No GitHub repository is set for '{game.GameId}'. Pass --repo owner/name or save the per-game source repository first."); + } + + PatreonGate? gate = null; + if (!string.IsNullOrWhiteSpace(patreonGateSource)) + { + gate = await payloads.ReadAsync( + patreonGateSource, + console.In, + cancellationToken); + } + if (assetDestination == ReleaseAssetDestination.GitHub && gate is not null) + { + throw CatalogCommandSupport.Validation( + "Patreon-gated bytes cannot be published to a public GitHub release."); + } + if (assetDestination == ReleaseAssetDestination.PatreonPost && gate is null) + { + throw CatalogCommandSupport.Validation( + "Patreon-post delivery requires --patreon-gate with campaign, tier, and post metadata."); + } + + return new ReleasePublishRequest( + resolved.ProjectPath, + resolved.Index.PluginId, + game.GameId, + parseResult.GetValue("--version")!, + parseResult.GetValue("--channel")!, + assetDestination == ReleaseAssetDestination.GitHub + ? CatalogCommandSupport.NormalizeGitHubRepo(repo!) + : string.Empty, + parseResult.GetValue("--zip")!, + parseResult.GetValue("--asset-name"), + parseResult.GetValue("--notes"), + parseResult.GetValue("--changelog-url"), + Patreon: gate); + } + + private static ReleaseAssetDestination ParseAssetDestination(string? value) => + value?.Trim().ToLowerInvariant() switch + { + null or "" or "github" => ReleaseAssetDestination.GitHub, + "server" => ReleaseAssetDestination.Server, + "patreon" or "patreon-post" => ReleaseAssetDestination.PatreonPost, + _ => throw CatalogCommandSupport.Validation( + "Asset destination must be github, server, or patreon-post.") + }; + + private static ModRelease FindRelease(PluginRepoIndex index, string gameId, string version, string channel) + { + CatalogCommandSupport.FindGame(index, gameId); + var matches = CatalogCommandSupport.GetReleasesForGame(index, gameId) + .Where(candidate => + string.Equals(candidate.Version, version, StringComparison.Ordinal) && + string.Equals(candidate.Channel, channel, StringComparison.Ordinal)) + .ToList(); + return matches.Count switch + { + 0 => throw CatalogCommandSupport.Validation($"Release {version} ({channel}) was not found for '{gameId}'."), + 1 => matches[0], + _ => throw CatalogCommandSupport.Conflict($"Multiple releases use identity {version} ({channel}) for '{gameId}'.") + }; + } + + private static void ThrowIfFailed(WorkflowResult result) + { + if (result.ErrorKind == WorkflowErrorKind.None) + return; + throw new WorkflowException( + result.ErrorKind, + result.Status, + result.Messages, + result.CompletedPhases); + } + + private static Argument GameArgument() => new("game-id") { Description = "Game id." }; + private static Argument VersionArgument(string name = "version") => new(name) { Description = "Release version." }; + private static Argument ChannelArgument(string name = "channel") => new(name) { Description = "Release channel." }; + + private static Option RequiredInputOption() => + new(CatalogCommandSupport.InputOptionName) + { + Description = "Path to a complete camelCase ModRelease JSON document, or - for standard input.", + Required = true + }; + + private static Option RequiredOption(string name, string description) => + new(name) { Description = description, Required = true }; +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/RootCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/RootCommands.cs new file mode 100644 index 0000000..984eb3d --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/RootCommands.cs @@ -0,0 +1,67 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using AccessibilityModManager.Authoring.Workflows; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class RootCommands +{ + public const string Version = "0.28.0"; + public const string VersionOptionName = "--version"; + public const string JsonOptionName = "--json"; + public const string QuietOptionName = "--quiet"; + public const string ProjectOptionName = "--project"; + public const string DryRunOptionName = "--dry-run"; + public const string YesOptionName = "--yes"; + public const string VerboseOptionName = "--verbose"; + + public static RootCommand Create() + { + var root = new RootCommand("Accessibility Mod Manager authoring CLI."); + + root.Add(new Option(JsonOptionName) + { + Description = "Write machine-readable JSON.", + Recursive = true + }); + + root.Add(new Option(QuietOptionName) + { + Description = "Suppress human status lines.", + Recursive = true + }); + + root.Add(new Option(ProjectOptionName) + { + Description = "Path to the author project directory.", + Recursive = true + }); + + root.Add(new Option(DryRunOptionName) + { + Description = "Validate and preview without making durable changes.", + Recursive = true + }); + + root.Add(new Option(YesOptionName) + { + Description = "Confirm prompts without bypassing validation or trust checks.", + Recursive = true + }); + + root.Add(new Option(VerboseOptionName) + { + Description = "Include detailed exception information.", + Recursive = true + }); + + root.SetAction(new Func(_ => + throw new WorkflowException( + WorkflowErrorKind.Usage, + "usage", + new[] { "A command is required. Use --help for available commands." }) + )); + + return root; + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/ScriptCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/ScriptCommands.cs new file mode 100644 index 0000000..1a01453 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/ScriptCommands.cs @@ -0,0 +1,158 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Authoring.Workflows; +using AccessibilityModManager.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class ScriptCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var outcomeWriter = services.GetRequiredService(); + var console = services.GetRequiredService(); + var projectContext = services.GetRequiredService(); + var indexFiles = services.GetRequiredService(); + var jsonPayloads = services.GetRequiredService(); + var catalogWorkflow = services.GetRequiredService(); + + var script = new Command("script", "Read or update default lifecycle scripts on a game."); + + var show = new Command("show", "Show one lifecycle script slot."); + var showGameIdArgument = new Argument("game-id") + { + Description = "Game id." + }; + var showSlotArgument = new Argument("slot") + { + Description = "Lifecycle slot: pre-install, post-install, or post-uninstall." + }; + show.Arguments.Add(showGameIdArgument); + show.Arguments.Add(showSlotArgument); + show.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projectContext, parseResult, cancellationToken); + var game = CatalogCommandSupport.FindGame(resolved.Index, parseResult.GetValue(showGameIdArgument)!); + var slot = CatalogCommandSupport.ParseSlot(parseResult.GetValue(showSlotArgument)!); + var selectedScript = GetSlot(game, slot); + + var result = CatalogCommandSupport.Success( + "scriptShown", + new + { + projectPath = resolved.ProjectPath, + pluginId = resolved.Index.PluginId, + gameId = game.GameId, + slot = ToToken(slot), + script = selectedScript + }, + selectedScript is null + ? $"Game '{game.GameId}' has no {ToToken(slot)} script." + : $"Loaded the {ToToken(slot)} script for '{game.GameId}'."); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var set = new Command("set", "Replace one lifecycle script slot from a camelCase JSON document."); + var setGameIdArgument = new Argument("game-id") + { + Description = "Game id." + }; + var setSlotArgument = new Argument("slot") + { + Description = "Lifecycle slot: pre-install, post-install, or post-uninstall." + }; + var inputOption = new Option(CatalogCommandSupport.InputOptionName) + { + Description = "Path to a camelCase JSON file, or - for standard input." + }; + set.Arguments.Add(setGameIdArgument); + set.Arguments.Add(setSlotArgument); + set.Options.Add(inputOption); + set.SetAction(async (parseResult, cancellationToken) => + { + var inputSource = parseResult.GetValue(inputOption); + if (string.IsNullOrWhiteSpace(inputSource)) + { + throw CatalogCommandSupport.Usage( + "script set requires --input ."); + } + + var gameId = parseResult.GetValue(setGameIdArgument)!; + var slot = CatalogCommandSupport.ParseSlot(parseResult.GetValue(setSlotArgument)!); + var scriptModel = await CatalogCommandSupport.ReadInputModelAsync( + jsonPayloads, + console, + inputSource, + cancellationToken); + + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projectContext, + indexFiles, + index => catalogWorkflow.SetLifecycleScript(index, gameId, slot, scriptModel), + "scriptSet", + $"Saved the {ToToken(slot)} script for '{gameId}'.", + $"Dry run: would save the {ToToken(slot)} script for '{gameId}'.", + cancellationToken); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + var clear = new Command("clear", "Remove one lifecycle script slot."); + var clearGameIdArgument = new Argument("game-id") + { + Description = "Game id." + }; + var clearSlotArgument = new Argument("slot") + { + Description = "Lifecycle slot: pre-install, post-install, or post-uninstall." + }; + clear.Arguments.Add(clearGameIdArgument); + clear.Arguments.Add(clearSlotArgument); + clear.SetAction(async (parseResult, cancellationToken) => + { + var gameId = parseResult.GetValue(clearGameIdArgument)!; + var slot = CatalogCommandSupport.ParseSlot(parseResult.GetValue(clearSlotArgument)!); + + var result = await CatalogCommandSupport.SaveMutationAsync( + parseResult, + projectContext, + indexFiles, + index => catalogWorkflow.ClearLifecycleScript(index, gameId, slot), + "scriptCleared", + $"Cleared the {ToToken(slot)} script for '{gameId}'.", + $"Dry run: would clear the {ToToken(slot)} script for '{gameId}'.", + cancellationToken); + + return CatalogCommandSupport.Complete(outcomeWriter, parseResult, result); + }); + + script.Subcommands.Add(show); + script.Subcommands.Add(set); + script.Subcommands.Add(clear); + return script; + } + + private static LifecycleScript? GetSlot(GameDefinition game, LifecycleSlot slot) => + slot switch + { + LifecycleSlot.PreInstall => game.DefaultPreInstall, + LifecycleSlot.PostInstall => game.DefaultPostInstall, + LifecycleSlot.PostUninstall => game.DefaultPostUninstall, + _ => throw new ArgumentOutOfRangeException(nameof(slot), slot, null) + }; + + private static string ToToken(LifecycleSlot slot) => + slot switch + { + LifecycleSlot.PreInstall => "pre-install", + LifecycleSlot.PostInstall => "post-install", + LifecycleSlot.PostUninstall => "post-uninstall", + _ => throw new ArgumentOutOfRangeException(nameof(slot), slot, null) + }; +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/ServerCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/ServerCommands.cs new file mode 100644 index 0000000..56aeeb1 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/ServerCommands.cs @@ -0,0 +1,280 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.Authoring.Workflows; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class ServerCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var writer = services.GetRequiredService(); + var console = services.GetRequiredService(); + var payloads = services.GetRequiredService(); + var projects = services.GetRequiredService(); + var workflow = services.GetRequiredService(); + var server = new Command("server", "Configure and operate the AuthorTool SFTP publishing server."); + + var status = new Command("status", "Show the saved server configuration without exposing its passphrase."); + status.SetAction(parseResult => Complete(writer, parseResult, workflow.GetStatus())); + + var configure = new Command("configure", "Validate and save a complete server configuration."); + var configureInput = RequiredInput("A camelCase ServerUploadConfig JSON document. KeyPassphrase must be empty."); + var passphraseStdin = new Option("--passphrase-stdin") + { + Description = "Read the SSH private-key passphrase from one redirected standard-input line." + }; + configure.Options.Add(configureInput); + configure.Options.Add(passphraseStdin); + configure.SetAction(async (parseResult, cancellationToken) => + { + var model = await CatalogCommandSupport.ReadInputModelAsync( + payloads, + console, + parseResult.GetValue(configureInput)!, + cancellationToken); + if (!string.IsNullOrEmpty(model.KeyPassphrase)) + { + throw CatalogCommandSupport.Usage( + "Don't put the SSH passphrase in JSON. Leave keyPassphrase empty and use concealed input or --passphrase-stdin."); + } + + string passphrase; + if (parseResult.GetValue(passphraseStdin)) + { + if (!console.IsInputRedirected) + throw CatalogCommandSupport.Usage("--passphrase-stdin requires redirected standard input."); + passphrase = await SecretReader.ReadAsync(console, cancellationToken); + } + else if (!console.IsInputRedirected) + { + console.WriteStatus("SSH private-key passphrase; press Enter if the key has none:"); + passphrase = await SecretReader.ReadAsync(console, cancellationToken); + } + else + { + passphrase = string.Empty; + } + + return Complete( + writer, + parseResult, + workflow.Configure( + new ServerConfigurationInput(model, passphrase), + CatalogCommandSupport.GetDryRun(parseResult))); + }); + + var clear = new Command("clear", "Remove the saved server configuration."); + clear.SetAction(parseResult => Complete( + writer, + parseResult, + workflow.Clear( + CatalogCommandSupport.GetYes(parseResult), + CatalogCommandSupport.GetDryRun(parseResult)))); + + var test = new Command("test", "Connect using the pinned host key and verify writable paths."); + test.SetAction(async (parseResult, cancellationToken) => + Complete(writer, parseResult, await workflow.TestAsync(cancellationToken))); + + var selfTest = new Command("self-test", "Exercise publish locking, SFTP read-back, and a non-live rehearsal."); + selfTest.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + return Complete( + writer, + parseResult, + await workflow.SelfTestAsync(resolved.Index.PluginId, cancellationToken)); + }); + + var release = new Command("release", "Inspect or upload immutable server-hosted packages."); + var releaseInspect = CreateReleaseCommand("inspect", "Inspect a version folder using the exact validated package bytes."); + releaseInspect.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var request = await BuildReleaseRequestAsync(parseResult, payloads, console, cancellationToken); + return Complete( + writer, + parseResult, + await workflow.InspectReleaseAsync(resolved.Index.PluginId, request, cancellationToken)); + }); + + var releaseUpload = CreateReleaseCommand("upload", "Upload the exact staged package and optional Patreon gate."); + releaseUpload.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + var request = await BuildReleaseRequestAsync(parseResult, payloads, console, cancellationToken); + return Complete( + writer, + parseResult, + await workflow.UploadReleaseAsync( + resolved.Index.PluginId, + request, + CatalogCommandSupport.GetYes(parseResult), + CatalogCommandSupport.GetDryRun(parseResult), + cancellationToken)); + }); + release.Subcommands.Add(releaseInspect); + release.Subcommands.Add(releaseUpload); + + var gate = new Command("gate", "Update or remove the Patreon gate on an already-published version."); + var gateSet = new Command("set", "Replace the campaign and tier ids enforced by the server."); + var gateSetGame = RequiredOption("--game", "Game id."); + var gateSetVersion = RequiredOption("--version", "Release version."); + var gateInput = RequiredInput("A camelCase PatreonGate JSON document."); + gateSet.Options.Add(gateSetGame); + gateSet.Options.Add(gateSetVersion); + gateSet.Options.Add(gateInput); + gateSet.SetAction(async (parseResult, cancellationToken) => + { + var model = await CatalogCommandSupport.ReadInputModelAsync( + payloads, + console, + parseResult.GetValue(gateInput)!, + cancellationToken); + return Complete( + writer, + parseResult, + await workflow.SetGateAsync( + parseResult.GetValue(gateSetGame)!, + parseResult.GetValue(gateSetVersion)!, + model, + CatalogCommandSupport.GetYes(parseResult), + CatalogCommandSupport.GetDryRun(parseResult), + cancellationToken)); + }); + + var gateRemove = new Command("remove", "Remove the gate and make an already-cataloged version public."); + var gateRemoveGame = RequiredOption("--game", "Game id."); + var gateRemoveVersion = RequiredOption("--version", "Release version."); + gateRemove.Options.Add(gateRemoveGame); + gateRemove.Options.Add(gateRemoveVersion); + gateRemove.SetAction(async (parseResult, cancellationToken) => + Complete( + writer, + parseResult, + await workflow.RemoveGateAsync( + parseResult.GetValue(gateRemoveGame)!, + parseResult.GetValue(gateRemoveVersion)!, + CatalogCommandSupport.GetYes(parseResult), + CatalogCommandSupport.GetDryRun(parseResult), + cancellationToken))); + gate.Subcommands.Add(gateSet); + gate.Subcommands.Add(gateRemove); + + var publishLock = new Command("lock", "Inspect or compare-and-break the server publish lock."); + var lockShow = new Command("show", "Display the lock and its exact fingerprint."); + lockShow.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + return Complete( + writer, + parseResult, + await workflow.InspectLockAsync(resolved.Index.PluginId, cancellationToken)); + }); + + var lockBreak = new Command("break", "Remove only the exact lock fingerprint previously displayed."); + var fingerprint = RequiredOption("--fingerprint", "Exact lock fingerprint from server lock show."); + lockBreak.Options.Add(fingerprint); + lockBreak.SetAction(async (parseResult, cancellationToken) => + { + var resolved = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + return Complete( + writer, + parseResult, + await workflow.BreakLockAsync( + resolved.Index.PluginId, + parseResult.GetValue(fingerprint)!, + CatalogCommandSupport.GetYes(parseResult), + CatalogCommandSupport.GetDryRun(parseResult), + cancellationToken)); + }); + publishLock.Subcommands.Add(lockShow); + publishLock.Subcommands.Add(lockBreak); + + server.Subcommands.Add(status); + server.Subcommands.Add(configure); + server.Subcommands.Add(clear); + server.Subcommands.Add(test); + server.Subcommands.Add(selfTest); + server.Subcommands.Add(release); + server.Subcommands.Add(gate); + server.Subcommands.Add(publishLock); + return server; + } + + private static Command CreateReleaseCommand(string name, string description) + { + var command = new Command(name, description); + command.Options.Add(RequiredOption("--game", "Game id.")); + command.Options.Add(RequiredOption("--version", "Release version.")); + command.Options.Add(RequiredOption("--zip", "Wrapped package ZIP.")); + command.Options.Add(new Option("--asset-name") + { + Description = "Published filename. Defaults to the ZIP filename." + }); + command.Options.Add(new Option("--gate-input") + { + Description = "Optional camelCase PatreonGate JSON document." + }); + return command; + } + + private static async Task BuildReleaseRequestAsync( + ParseResult parseResult, + JsonPayloadService payloads, + ICliConsole console, + CancellationToken cancellationToken) + { + var zip = Path.GetFullPath(parseResult.GetValue("--zip")!); + var gateSource = parseResult.GetValue("--gate-input"); + var gate = string.IsNullOrWhiteSpace(gateSource) + ? null + : await CatalogCommandSupport.ReadInputModelAsync( + payloads, + console, + gateSource, + cancellationToken); + return new ServerReleaseRequest( + parseResult.GetValue("--game")!, + parseResult.GetValue("--version")!, + parseResult.GetValue("--asset-name") ?? Path.GetFileName(zip), + zip, + gate); + } + + private static Option RequiredInput(string description) => + new(CatalogCommandSupport.InputOptionName) + { + Description = description, + Required = true + }; + + private static Option RequiredOption(string name, string description) => + new(name) + { + Description = description, + Required = true + }; + + private static int Complete( + OutcomeWriter writer, + ParseResult parseResult, + WorkflowResult result) + { + if (result.ErrorKind != WorkflowErrorKind.None) + { + throw new WorkflowException( + result.ErrorKind, + result.Status, + result.Messages, + result.CompletedPhases); + } + + return CatalogCommandSupport.Complete(writer, parseResult, result); + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Commands/SigningCommands.cs b/src/AccessibilityModManager.AuthorCli/Commands/SigningCommands.cs new file mode 100644 index 0000000..0ccf716 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Commands/SigningCommands.cs @@ -0,0 +1,221 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.Authoring.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli.Commands; + +public static class SigningCommands +{ + public static Command Create(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + + var writer = services.GetRequiredService(); + var console = services.GetRequiredService(); + var projects = services.GetRequiredService(); + var workflow = services.GetRequiredService(); + var signing = new Command("signing", "Manage catalog signing keys, claims, and publisher-head recovery."); + + var status = new Command("status", "Show the public identity and local state of one signing key."); + var statusPlugin = Required("--plugin", "Plugin id."); + status.Options.Add(statusPlugin); + status.SetAction(parseResult => Complete( + writer, parseResult, workflow.GetStatus(parseResult.GetValue(statusPlugin)!))); + + var create = new Command("create", "Create a new per-plugin signing key and store it encrypted."); + var createPlugin = Required("--plugin", "Plugin id."); + var createStdin = SecretOption("--passphrase-stdin", "Read the new key passphrase from redirected input."); + create.Options.Add(createPlugin); + create.Options.Add(createStdin); + create.SetAction(async (parseResult, cancellationToken) => + { + var passphrase = await ReadSecretAsync( + console, parseResult.GetValue(createStdin), + "Signing-key passphrase:", "--passphrase-stdin", cancellationToken); + return Complete(writer, parseResult, + workflow.Create(parseResult.GetValue(createPlugin)!, passphrase)); + }); + + var export = new Command("export", "Write an encrypted portable key and publisher-state backup."); + var exportPlugin = Required("--plugin", "Plugin id."); + var destination = Required("--destination", "Destination backup JSON path."); + var exportStdin = SecretOption("--passphrase-stdin", "Read the separate backup passphrase from redirected input."); + export.Options.Add(exportPlugin); + export.Options.Add(destination); + export.Options.Add(exportStdin); + export.SetAction(async (parseResult, cancellationToken) => + { + var passphrase = await ReadSecretAsync( + console, parseResult.GetValue(exportStdin), + "Backup passphrase (use a different passphrase from the local key):", + "--passphrase-stdin", cancellationToken); + return Complete(writer, parseResult, workflow.Export( + parseResult.GetValue(exportPlugin)!, + parseResult.GetValue(destination)!, + passphrase)); + }); + + var import = new Command("import", "Restore an encrypted key backup and its publisher history."); + var source = Required("--source", "Source backup JSON path."); + var importStdin = SecretOption("--passphrase-stdin", "Read the backup passphrase from redirected input."); + import.Options.Add(source); + import.Options.Add(importStdin); + import.SetAction(async (parseResult, cancellationToken) => + { + var passphrase = await ReadSecretAsync( + console, parseResult.GetValue(importStdin), + "Backup passphrase:", "--passphrase-stdin", cancellationToken); + return Complete(writer, parseResult, + workflow.Import(parseResult.GetValue(source)!, passphrase)); + }); + + var change = new Command("change-passphrase", "Re-encrypt a local key without changing its public identity."); + var changePlugin = Required("--plugin", "Plugin id."); + var passphrasesStdin = SecretOption( + "--passphrases-stdin", + "Read current and new passphrases from two redirected input lines, in that order."); + change.Options.Add(changePlugin); + change.Options.Add(passphrasesStdin); + change.SetAction(async (parseResult, cancellationToken) => + { + string current; + string replacement; + if (parseResult.GetValue(passphrasesStdin)) + { + RequireRedirected(console, "--passphrases-stdin"); + current = await SecretReader.ReadAsync(console, cancellationToken); + replacement = await SecretReader.ReadAsync(console, cancellationToken); + } + else + { + if (console.IsInputRedirected) + throw CatalogCommandSupport.Usage( + "Redirected passphrases require --passphrases-stdin; never put secrets on the command line."); + console.WriteStatus("Current signing-key passphrase:"); + current = await SecretReader.ReadAsync(console, cancellationToken); + console.WriteStatus("New signing-key passphrase:"); + replacement = await SecretReader.ReadAsync(console, cancellationToken); + } + + return Complete(writer, parseResult, workflow.ChangePassphrase( + parseResult.GetValue(changePlugin)!, current, replacement)); + }); + + var claims = new Command("claims", "Preview or sign the exact claims represented by index.json."); + var claimsPreview = new Command("preview", "Preview the next signed publish without opening a key or writing a journal."); + claimsPreview.SetAction(async (parseResult, cancellationToken) => + { + var project = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + return Complete(writer, parseResult, + await workflow.PreviewClaimsAsync(project.ProjectPath, cancellationToken)); + }); + var claimsSign = new Command("sign", "Sign and journal the reviewed publish without uploading it."); + var deletionToken = new Option("--deletions-token") + { + Description = "Exact permanent-removal token returned by claims preview." + }; + claimsSign.Options.Add(deletionToken); + claimsSign.SetAction(async (parseResult, cancellationToken) => + { + var project = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + return Complete(writer, parseResult, await workflow.SignClaimsAsync( + project.ProjectPath, + parseResult.GetValue(deletionToken) ?? "", + CatalogCommandSupport.GetYes(parseResult), + cancellationToken)); + }); + claims.Subcommands.Add(claimsPreview); + claims.Subcommands.Add(claimsSign); + + var head = new Command("head", "Inspect and safely settle the publisher journal."); + var headStatus = new Command("status", "Show every publisher-head record for one plugin."); + var headPlugin = Required("--plugin", "Plugin id."); + headStatus.Options.Add(headPlugin); + headStatus.SetAction(parseResult => Complete( + writer, parseResult, workflow.GetHeadStatus(parseResult.GetValue(headPlugin)!))); + + var headConfirm = new Command("confirm", "Confirm that the exact pending bytes are already live."); + headConfirm.SetAction(async (parseResult, cancellationToken) => + { + RequireYes(parseResult, "Confirming a publisher head"); + var project = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + return Complete(writer, parseResult, + await workflow.ConfirmHeadAsync(project.ProjectPath, cancellationToken)); + }); + + var commitPending = new Command("commit-pending", "Commit a pending head only after proving it landed."); + commitPending.SetAction(async (parseResult, cancellationToken) => + { + var project = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + return Complete(writer, parseResult, await workflow.CommitPendingAsync( + project.ProjectPath, CatalogCommandSupport.GetYes(parseResult), cancellationToken)); + }); + + var resume = new Command("resume", "Publish the exact journalled bytes for an interrupted attempt."); + resume.SetAction(async (parseResult, cancellationToken) => + { + var project = await CatalogCommandSupport.ResolveProjectAsync(projects, parseResult, cancellationToken); + return Complete(writer, parseResult, await workflow.ResumeHeadAsync( + project.ProjectPath, CatalogCommandSupport.GetYes(parseResult), cancellationToken)); + }); + + head.Subcommands.Add(headStatus); + head.Subcommands.Add(headConfirm); + head.Subcommands.Add(commitPending); + head.Subcommands.Add(resume); + signing.Subcommands.Add(status); + signing.Subcommands.Add(create); + signing.Subcommands.Add(export); + signing.Subcommands.Add(import); + signing.Subcommands.Add(change); + signing.Subcommands.Add(claims); + signing.Subcommands.Add(head); + return signing; + } + + private static Option Required(string name, string description) => + new(name) { Description = description, Required = true }; + + private static Option SecretOption(string name, string description) => + new(name) { Description = description }; + + private static async Task ReadSecretAsync( + ICliConsole console, + bool fromStdin, + string prompt, + string stdinOption, + CancellationToken ct) + { + if (fromStdin) + { + RequireRedirected(console, stdinOption); + return await SecretReader.ReadAsync(console, ct); + } + + if (console.IsInputRedirected) + throw CatalogCommandSupport.Usage( + $"Redirected secret input requires {stdinOption}; never put passphrases on the command line."); + console.WriteStatus(prompt); + return await SecretReader.ReadAsync(console, ct); + } + + private static void RequireRedirected(ICliConsole console, string option) + { + if (!console.IsInputRedirected) + throw CatalogCommandSupport.Usage($"{option} requires redirected standard input."); + } + + private static void RequireYes(ParseResult parseResult, string operation) + { + if (!CatalogCommandSupport.GetYes(parseResult)) + throw CatalogCommandSupport.Conflict($"{operation} requires --yes after reviewing the pending state."); + } + + private static int Complete(OutcomeWriter writer, ParseResult parseResult, WorkflowResult result) + { + if (result.ErrorKind != WorkflowErrorKind.None) + throw new WorkflowException(result.ErrorKind, result.Status, result.Messages, result.CompletedPhases); + return CatalogCommandSupport.Complete(writer, parseResult, result); + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Console/AccessibleText.cs b/src/AccessibilityModManager.AuthorCli/Console/AccessibleText.cs new file mode 100644 index 0000000..b33c746 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Console/AccessibleText.cs @@ -0,0 +1,33 @@ +using System.Text.RegularExpressions; + +namespace AccessibilityModManager.AuthorCli.Console; + +internal static partial class AccessibleText +{ + public static IReadOnlyList MeaningfulLines(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return []; + + var plain = AnsiSequence().Replace(value, string.Empty) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + + return plain + .Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Any(char.IsLetterOrDigit)) + .ToArray(); + } + + public static string StatusOrFallback(string? value, bool failed) + { + var lines = MeaningfulLines(value); + return lines.Count > 0 + ? string.Join(' ', lines) + : failed ? "Operation failed." : "Operation completed."; + } + + [GeneratedRegex("\\u001B\\[[0-?]*[ -/]*[@-~]", RegexOptions.CultureInvariant)] + private static partial Regex AnsiSequence(); +} diff --git a/src/AccessibilityModManager.AuthorCli/Console/CliConsole.cs b/src/AccessibilityModManager.AuthorCli/Console/CliConsole.cs new file mode 100644 index 0000000..9bbc0dd --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Console/CliConsole.cs @@ -0,0 +1,67 @@ +namespace AccessibilityModManager.AuthorCli.Console; + +public interface ICliConsole +{ + TextReader In { get; } + TextWriter Out { get; } + TextWriter Error { get; } + bool IsInputRedirected { get; } + void WriteStatus(string message); + void WriteWarning(string message) => WriteLines(Error, message); + + private static void WriteLines(TextWriter writer, string message) + { + foreach (var line in AccessibleText.MeaningfulLines(message)) + writer.WriteLine(line); + writer.Flush(); + } +} + +public sealed class CliConsole : ICliConsole +{ + public CliConsole(TextReader input, TextWriter output, TextWriter error, bool isInputRedirected) + { + In = input ?? throw new ArgumentNullException(nameof(input)); + Out = output ?? throw new ArgumentNullException(nameof(output)); + Error = error ?? throw new ArgumentNullException(nameof(error)); + IsInputRedirected = isInputRedirected; + } + + public TextReader In { get; } + public TextWriter Out { get; } + public TextWriter Error { get; } + public bool IsInputRedirected { get; } + public bool Quiet { get; set; } + + public static CliConsole CreateSystem() => + new( + TextReader.Synchronized(System.Console.In), + TextWriter.Synchronized(System.Console.Out), + TextWriter.Synchronized(System.Console.Error), + System.Console.IsInputRedirected); + + public void WriteStatus(string message) + { + ArgumentNullException.ThrowIfNull(message); + + if (Quiet) + { + return; + } + + WriteLines(message); + } + + public void WriteWarning(string message) + { + ArgumentNullException.ThrowIfNull(message); + WriteLines(message); + } + + private void WriteLines(string message) + { + foreach (var line in AccessibleText.MeaningfulLines(message)) + Error.WriteLine(line); + Error.Flush(); + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Console/ExitCodes.cs b/src/AccessibilityModManager.AuthorCli/Console/ExitCodes.cs new file mode 100644 index 0000000..9b5b761 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Console/ExitCodes.cs @@ -0,0 +1,28 @@ +using AccessibilityModManager.Authoring.Workflows; + +namespace AccessibilityModManager.AuthorCli.Console; + +public enum CliExitCode +{ + Success = 0, + Usage = 2, + Validation = 3, + Authentication = 4, + Conflict = 5, + Cancelled = 130 +} + +public static class ExitCodes +{ + public static CliExitCode From(WorkflowErrorKind errorKind) => + errorKind switch + { + WorkflowErrorKind.None => CliExitCode.Success, + WorkflowErrorKind.Usage => CliExitCode.Usage, + WorkflowErrorKind.Validation => CliExitCode.Validation, + WorkflowErrorKind.Authentication => CliExitCode.Authentication, + WorkflowErrorKind.Conflict => CliExitCode.Conflict, + WorkflowErrorKind.Cancelled => CliExitCode.Cancelled, + _ => throw new ArgumentOutOfRangeException(nameof(errorKind), errorKind, null) + }; +} diff --git a/src/AccessibilityModManager.AuthorCli/Console/OutcomeWriter.cs b/src/AccessibilityModManager.AuthorCli/Console/OutcomeWriter.cs new file mode 100644 index 0000000..19e42cf --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Console/OutcomeWriter.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AccessibilityModManager.Authoring.Workflows; + +namespace AccessibilityModManager.AuthorCli.Console; + +public sealed class OutcomeWriter +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + private readonly ICliConsole _console; + + public OutcomeWriter(ICliConsole console) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + } + + public void Write(WorkflowResult result, bool json) + { + ArgumentNullException.ThrowIfNull(result); + + if (json) + { + WriteJson(result); + return; + } + + WriteHuman(result); + } + + private void WriteHuman(WorkflowResult result) + { + var writer = result.ErrorKind == WorkflowErrorKind.None + ? _console.Out + : _console.Error; + + var messages = result.Messages + .SelectMany(AccessibleText.MeaningfulLines) + .ToArray(); + if (messages.Length > 0) + { + foreach (var message in messages) + { + writer.WriteLine(message); + } + + writer.Flush(); + return; + } + + if (result.Value is string text) + { + var lines = AccessibleText.MeaningfulLines(text); + if (lines.Count > 0) + { + foreach (var line in lines) + writer.WriteLine(line); + writer.Flush(); + return; + } + } + + writer.WriteLine(AccessibleText.StatusOrFallback( + result.Status, + result.ErrorKind != WorkflowErrorKind.None)); + writer.Flush(); + } + + private void WriteJson(WorkflowResult result) + { + var writer = result.ErrorKind == WorkflowErrorKind.None + ? _console.Out + : _console.Error; + + var payload = new JsonOutcome + { + Status = AccessibleText.StatusOrFallback( + result.Status, + result.ErrorKind != WorkflowErrorKind.None), + Value = result.Value, + Messages = result.Messages.SelectMany(AccessibleText.MeaningfulLines).ToArray(), + ErrorKind = result.ErrorKind, + CompletedPhases = result.CompletedPhases is { Count: > 0 } + ? result.CompletedPhases + : null + }; + + writer.WriteLine(JsonSerializer.Serialize(payload, JsonOptions)); + writer.Flush(); + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } + + private sealed class JsonOutcome + { + public required string Status { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Value { get; init; } + + public required IReadOnlyList Messages { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public WorkflowErrorKind ErrorKind { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? CompletedPhases { get; init; } + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Console/SecretReader.cs b/src/AccessibilityModManager.AuthorCli/Console/SecretReader.cs new file mode 100644 index 0000000..95f82f3 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Console/SecretReader.cs @@ -0,0 +1,114 @@ +using System.Text; + +namespace AccessibilityModManager.AuthorCli.Console; + +public static class SecretReader +{ + public static async Task ReadAsync(ICliConsole console, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(console); + + if (!console.IsInputRedirected && console is CliConsole) + { + return await ReadInteractiveAsync(ct); + } + + return await ReadRedirectedAsync(console.In, ct); + } + + private static async Task ReadInteractiveAsync(CancellationToken ct) + { + var builder = new StringBuilder(); + var originalTreatControlCAsInput = System.Console.TreatControlCAsInput; + + System.Console.TreatControlCAsInput = true; + try + { + while (true) + { + ct.ThrowIfCancellationRequested(); + + while (!System.Console.KeyAvailable) + { + await Task.Delay(25, ct); + } + + var key = System.Console.ReadKey(intercept: true); + + if ((key.Modifiers & ConsoleModifiers.Control) != 0 && key.Key == ConsoleKey.C) + { + throw new OperationCanceledException(ct); + } + + if (key.Key == ConsoleKey.Enter) + { + return builder.ToString(); + } + + if (key.Key == ConsoleKey.Backspace) + { + if (builder.Length > 0) + { + builder.Length--; + } + + continue; + } + + if (!char.IsControl(key.KeyChar)) + { + builder.Append(key.KeyChar); + } + } + } + finally + { + System.Console.TreatControlCAsInput = originalTreatControlCAsInput; + } + } + + private static async Task ReadRedirectedAsync(TextReader input, CancellationToken ct) + { + var builder = new StringBuilder(); + var buffer = new char[1]; + + while (true) + { + ct.ThrowIfCancellationRequested(); + + var read = await input.ReadAsync(buffer, 0, 1).WaitAsync(ct); + if (read == 0) + { + return builder.ToString(); + } + + var value = buffer[0]; + switch (value) + { + case '\u0003': + throw new OperationCanceledException(ct); + + case '\b': + case '\u007F': + if (builder.Length > 0) + { + builder.Length--; + } + + break; + + case '\r': + case '\n': + return builder.ToString(); + + default: + if (!char.IsControl(value)) + { + builder.Append(value); + } + + break; + } + } + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Program.cs b/src/AccessibilityModManager.AuthorCli/Program.cs new file mode 100644 index 0000000..f7a5384 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Program.cs @@ -0,0 +1,118 @@ +using System.CommandLine; +using AccessibilityModManager.AuthorCli.Commands; +using AccessibilityModManager.AuthorCli.Console; +using AccessibilityModManager.Authoring.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace AccessibilityModManager.AuthorCli; + +public static class Program +{ + public static async Task Main(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + + using var services = CliServices.Create(); + return await RunAsync(args, services); + } + + public static async Task RunAsync(string[] args, IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(services); + + var console = services.GetRequiredService(); + + if (IsExactVersionRequest(args)) + { + await console.Out.WriteLineAsync(RootCommands.Version); + await console.Out.FlushAsync(); + return (int)CliExitCode.Success; + } + + var outcomeWriter = services.GetRequiredService(); + var root = CommandCatalog.CreateRoot(services); + + var parseInputs = args.Length == 0 ? new[] { "--help" } : args; + var parseResult = root.Parse(parseInputs); + + var json = HasFlag(args, RootCommands.JsonOptionName) || GetBooleanOption(parseResult, RootCommands.JsonOptionName); + var verbose = HasFlag(args, RootCommands.VerboseOptionName) || GetBooleanOption(parseResult, RootCommands.VerboseOptionName); + + if (console is CliConsole cliConsole) + { + cliConsole.Quiet = GetBooleanOption(parseResult, RootCommands.QuietOptionName); + } + + if (parseResult.Errors.Count > 0) + { + var messages = parseResult.Errors.Select(error => error.Message).ToArray(); + outcomeWriter.Write( + new WorkflowResult("usage", null, messages, WorkflowErrorKind.Usage), + json); + return (int)ExitCodes.From(WorkflowErrorKind.Usage); + } + + try + { + return await parseResult.InvokeAsync(new InvocationConfiguration + { + Output = console.Out, + Error = console.Error, + EnableDefaultExceptionHandler = false, + ProcessTerminationTimeout = TimeSpan.FromSeconds(2) + }); + } + catch (OperationCanceledException) + { + outcomeWriter.Write( + new WorkflowResult( + "cancelled", + null, + new[] { "Operation cancelled." }, + WorkflowErrorKind.Cancelled), + json); + return (int)CliExitCode.Cancelled; + } + catch (WorkflowException ex) + { + outcomeWriter.Write(ex.ToResult(verbose: verbose), json); + return (int)ExitCodes.From(ex.ErrorKind); + } + catch (Exception ex) + { + var messages = verbose + ? new[] { ex.Message, ex.ToString() } + : new[] { ex.Message }; + + outcomeWriter.Write( + new WorkflowResult( + "failed", + null, + messages, + WorkflowErrorKind.Validation), + json); + + return (int)CliExitCode.Validation; + } + } + + private static bool IsExactVersionRequest(string[] args) => + args.Length == 1 && + string.Equals(args[0], RootCommands.VersionOptionName, StringComparison.Ordinal); + + private static bool HasFlag(string[] args, string optionName) => + args.Any(arg => string.Equals(arg, optionName, StringComparison.Ordinal)); + + private static bool GetBooleanOption(ParseResult parseResult, string optionName) + { + try + { + return parseResult.GetValue(optionName); + } + catch + { + return false; + } + } +} diff --git a/src/AccessibilityModManager.AuthorCli/Properties/PublishProfiles/win-x64.pubxml b/src/AccessibilityModManager.AuthorCli/Properties/PublishProfiles/win-x64.pubxml new file mode 100644 index 0000000..c54ef40 --- /dev/null +++ b/src/AccessibilityModManager.AuthorCli/Properties/PublishProfiles/win-x64.pubxml @@ -0,0 +1,12 @@ + + + Release + net10.0-windows + win-x64 + true + false + true + none + false + + diff --git a/src/AccessibilityModManager.AuthorTool/AccessibilityModManager.AuthorTool.csproj b/src/AccessibilityModManager.AuthorTool/AccessibilityModManager.AuthorTool.csproj index 0bea13a..bea214c 100644 --- a/src/AccessibilityModManager.AuthorTool/AccessibilityModManager.AuthorTool.csproj +++ b/src/AccessibilityModManager.AuthorTool/AccessibilityModManager.AuthorTool.csproj @@ -1,6 +1,7 @@ + @@ -10,7 +11,6 @@ - diff --git a/src/AccessibilityModManager.AuthorTool/App.xaml.cs b/src/AccessibilityModManager.AuthorTool/App.xaml.cs index 3aad71a..84fd48e 100644 --- a/src/AccessibilityModManager.AuthorTool/App.xaml.cs +++ b/src/AccessibilityModManager.AuthorTool/App.xaml.cs @@ -1,5 +1,6 @@ using System.Net.Http; using System.Windows; +using AccessibilityModManager.Authoring.Workflows; using AccessibilityModManager.AuthorTool.Services; using AccessibilityModManager.AuthorTool.ViewModels; using AccessibilityModManager.AuthorTool.Views; @@ -41,10 +42,25 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); // The signed-catalog side. Registered together because they only mean anything together: // the head store is this machine's memory of what it published, the key store holds what it @@ -57,6 +73,18 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton( + sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); @@ -154,6 +182,7 @@ private static IndexEditorViewModel CreateIndexEditor(IServiceProvider sp, MainV sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), (pluginId, trust) => ShowClaimSigningDialog(sp, pluginId, trust)); } @@ -225,7 +254,8 @@ private static void ShowServerUploadSettingsDialog(IServiceProvider sp) ConfirmDialog, BrowseForFile, showBuildPackage, - existingRelease); + existingRelease, + sp.GetRequiredService()); var dialog = new ReleaseDialog(vm) { @@ -243,7 +273,7 @@ private static void ShowServerUploadSettingsDialog(IServiceProvider sp) { var vm = new BuildPackageDialogViewModel( gameId, gameDisplayName, pluginId, suggestedVersion, deps, - sp.GetRequiredService(), + sp.GetRequiredService(), BrowseForFolder, ShowInfoDialog, sp.GetRequiredService(), diff --git a/src/AccessibilityModManager.AuthorTool/BuildFlags.cs b/src/AccessibilityModManager.AuthorTool/BuildFlags.cs index a5652be..92b15f9 100644 --- a/src/AccessibilityModManager.AuthorTool/BuildFlags.cs +++ b/src/AccessibilityModManager.AuthorTool/BuildFlags.cs @@ -1,15 +1,11 @@ namespace AccessibilityModManager.AuthorTool; /// -/// Compile-time toggles. is true when the build was produced -/// with -p:DefineConstants=REGISTRY_ADMIN (or via the build script's -Admin -/// switch). The signing UI is hidden in normal user builds. +/// Compile-time toggles shared with the headless authoring workflows. The signing UI is hidden in +/// normal user builds. /// internal static class BuildFlags { -#if REGISTRY_ADMIN - public const bool IsRegistryAdmin = true; -#else - public const bool IsRegistryAdmin = false; -#endif + public const bool IsRegistryAdmin = + AccessibilityModManager.Authoring.Workflows.AuthoringBuildFlags.IsRegistryAdmin; } diff --git a/src/AccessibilityModManager.AuthorTool/Services/Sha256HashService.cs b/src/AccessibilityModManager.AuthorTool/Services/Sha256HashService.cs deleted file mode 100644 index 886c043..0000000 --- a/src/AccessibilityModManager.AuthorTool/Services/Sha256HashService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.IO; -using System.Security.Cryptography; - -namespace AccessibilityModManager.AuthorTool.Services; - -public sealed class Sha256HashService -{ - public async Task ComputeAsync(string filePath, CancellationToken ct = default) - { - await using var stream = File.OpenRead(filePath); - var hash = await SHA256.HashDataAsync(stream, ct); - return Convert.ToHexString(hash).ToLowerInvariant(); - } -} diff --git a/src/AccessibilityModManager.AuthorTool/ViewModels/BuildPackageDialogViewModel.cs b/src/AccessibilityModManager.AuthorTool/ViewModels/BuildPackageDialogViewModel.cs index d70262d..5cd2fcc 100644 --- a/src/AccessibilityModManager.AuthorTool/ViewModels/BuildPackageDialogViewModel.cs +++ b/src/AccessibilityModManager.AuthorTool/ViewModels/BuildPackageDialogViewModel.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.IO; +using AccessibilityModManager.Authoring.Workflows; using AccessibilityModManager.AuthorTool.Services; using AccessibilityModManager.Core.Models; using CommunityToolkit.Mvvm.ComponentModel; @@ -10,7 +11,7 @@ namespace AccessibilityModManager.AuthorTool.ViewModels; public sealed partial class BuildPackageDialogViewModel : ObservableObject { - private readonly ManifestBuilderService _builder; + private readonly AuthoringWorkflowFacade _workflows; private readonly string _gameId; private readonly string _pluginId; private readonly IList _dependencies; @@ -44,7 +45,7 @@ public BuildPackageDialogViewModel( string pluginId, string suggestedVersion, IList dependencies, - ManifestBuilderService builder, + AuthoringWorkflowFacade workflows, Func browseForFolder, Action showInfoDialog, ILogger logger, @@ -55,7 +56,7 @@ public BuildPackageDialogViewModel( _pluginId = pluginId; _version = suggestedVersion; _dependencies = dependencies; - _builder = builder; + _workflows = workflows; _browseForFolder = browseForFolder; _showInfoDialog = showInfoDialog; _logger = logger; @@ -117,14 +118,16 @@ private async Task BuildAsync() var fileName = $"{_gameId}-v{sanitizedVersion}-amm.zip"; var outputPath = Path.Combine(ManifestBuilderService.GetBuildsDirectory(), fileName); - var result = await _builder.BuildPackageAsync( - SourceFolder, - _gameId, - _pluginId, - sanitizedVersion, - _dependencies, - outputPath, - scripts: _scripts); + var result = await _workflows.BuildPackageAsync( + new PackageBuildRequest( + SourceFolder, + outputPath, + _pluginId, + _gameId, + sanitizedVersion, + _dependencies.ToList(), + _scripts), + CancellationToken.None); ResultZipPath = result.ZipPath; StatusMessage = $"Built {result.FileCount} files. Returning to release dialog."; diff --git a/src/AccessibilityModManager.AuthorTool/ViewModels/DependencyPresets.cs b/src/AccessibilityModManager.AuthorTool/ViewModels/DependencyPresets.cs index 78c7944..e7270ad 100644 --- a/src/AccessibilityModManager.AuthorTool/ViewModels/DependencyPresets.cs +++ b/src/AccessibilityModManager.AuthorTool/ViewModels/DependencyPresets.cs @@ -1,3 +1,4 @@ +using AccessibilityModManager.Authoring.Workflows; using AccessibilityModManager.Core.Models; namespace AccessibilityModManager.AuthorTool.ViewModels; @@ -19,165 +20,13 @@ public static class DependencyPresetsBag public static class DependencyPresets { - public static IReadOnlyList All { get; } = new[] - { - new DependencyPreset - { - DisplayName = "Emulator (portable app)", - Description = "The emulator itself, delivered as a portable ZIP. \"This dependency is the " + - "game itself\" is already ticked and the auto-install kind is set to extractApp. " + - "Set the game's Exe name (General tab) to the emulator's exe, then paste the " + - "ZIP's HTTPS URL below and click \"Fetch from URL\" for the SHA256.", - Build = () => new Dependency + public static IReadOnlyList All { get; } = + DependencyPresetCatalog.All + .Select(preset => new DependencyPreset { - Id = "emulator", - Type = "system", - Required = true, - IsGameInstaller = true, - Fix = new DependencyFix - { - // Author fills these in: the emulator ZIP's HTTPS URL, and its SHA256 (Fetch from URL). - DownloadUrl = "", - AutoInstall = new ExtractAppAutoInstall { Sha256 = "" } - } - } - }, - new DependencyPreset - { - DisplayName = "MelonLoader", - Description = "MelonLoader runtime; checked by version.dll in the game folder.", - Build = () => new Dependency - { - Id = "melonloader", - Type = "framework", - Required = true, - Check = new DependencyCheck { FilePath = "version.dll" }, - Fix = new DependencyFix { DownloadUrl = "https://github.com/LavaGang/MelonLoader/releases" } - } - }, - new DependencyPreset - { - DisplayName = "BepInEx", - Description = "BepInEx framework; checked by winhttp.dll in the game folder.", - Build = () => new Dependency - { - Id = "bepinex", - Type = "framework", - Required = true, - Check = new DependencyCheck { FilePath = "winhttp.dll" }, - Fix = new DependencyFix { DownloadUrl = "https://github.com/BepInEx/BepInEx/releases" } - } - }, - new DependencyPreset - { - DisplayName = ".NET 10 Desktop Runtime", - Description = "Required for managers/mods that need the .NET 10 runtime. Checked via " + - "the runtime's registry record (version-named entries; the x64 runtime " + - "records under the 32-bit registry view, which the checker probes automatically).", - Build = () => new Dependency - { - Id = "dotnet-10-desktop", - Type = "system", - Required = true, - MinVersion = "10.0.0", - Check = new DependencyCheck - { - // Deliberately NO RegistryValue and NO view pin: the installed versions are - // the value NAMES under this key (highest wins vs MinVersion), and the x64 - // runtime writes it under the 32-bit view — the default both-views probe is - // what finds it (audit finding 10; verified against a real install 2026-07-25). - RegistryKey = @"SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App" - }, - Fix = new DependencyFix { DownloadUrl = "https://dotnet.microsoft.com/download/dotnet/10.0" } - } - }, - Net9Desktop( - bits: 64, - registryArch: "x64", - installerUrl: Net9X64Url, - sha256: Net9X64Sha256), - Net9Desktop( - bits: 32, - registryArch: "x86", - installerUrl: Net9X86Url, - sha256: Net9X86Sha256) - }; - - // .NET 9.0.18, the current 9.0 patch as of 2026-08-04. Both URLs came from Microsoft's own - // release metadata (release-metadata/9.0/releases.json) rather than being typed out, and each - // file was downloaded and its SHA512 checked against the hash that metadata publishes — so the - // SHA256 below is provably the hash of the genuine installer, not merely of whatever answered. - // - // Pinned to an exact patch on purpose: the manager's SHA256 gate is absolute, so an "always - // latest" address would start failing the moment Microsoft ships 9.0.19. Bumping this preset is - // a deliberate edit, and the hash has to be re-derived with it. - private const string Net9X64Url = - "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.18/windowsdesktop-runtime-9.0.18-win-x64.exe"; - private const string Net9X64Sha256 = - "12cd00688fc9f8f5187d25911bf656db61998c264f03eef4022ff2d9321d6982"; - - private const string Net9X86Url = - "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.18/windowsdesktop-runtime-9.0.18-win-x86.exe"; - private const string Net9X86Sha256 = - "a90bc401a7838f036a4d615ca7031099b4b950ed6a8f59f59c44150c6ad7d648"; - - /// - /// The .NET 9 Desktop Runtime, in one architecture, ready to install without the author filling - /// anything in. - /// - /// Which one a game needs is not a detail. A 32-bit game loads the 32-bit runtime - /// and a 64-bit game the 64-bit one; installing the wrong one leaves the mod unable to start - /// with nothing obviously wrong. They install side by side, so a machine can want both — which - /// is why these are two presets with two ids rather than one with a switch. - /// - /// How the check works. Installed versions are the value NAMES under the key, and - /// the checker takes the highest and compares it against MinVersion. The architecture is part of - /// the KEY PATH (…\x64\… vs …\x86\…), not the registry view — both actually live under - /// WOW6432Node, and the checker probes both views by default, which is what finds them. Verified - /// against a real machine on 2026-08-04, where x64 held 6.0.5 through 10.0.8 and x86 held 5.0.17 - /// through 10.0.10. - /// - /// The one thing to know: "highest wins" means a machine with only .NET 10 passes a - /// MinVersion of 9.0.0, and a mod built for net9.0 will NOT run on 10 alone — .NET rolls forward - /// across patches, not across major versions. Getting that exactly right needs the check to be - /// able to say "some 9.x", which it currently cannot express. - /// - private static DependencyPreset Net9Desktop(int bits, string registryArch, string installerUrl, string sha256) => - new() - { - DisplayName = $".NET 9 Desktop Runtime ({bits}-bit)", - Description = - $"The {bits}-bit .NET 9 Desktop Runtime (9.0.18), for a {bits}-bit game. The download " + - "address and SHA256 are already filled in and verified, and the manager installs it " + - "silently with the user's consent. Checked by the runtime's own registry record, so " + - "any 9.x or newer counts — no exact patch to keep up to date. Pick the architecture " + - "that matches the game: the wrong one leaves the mod unable to start.", - Build = () => new Dependency - { - Id = $"dotnet-9-desktop-{registryArch}", - Type = "system", - Required = true, - MinVersion = "9.0.0", - Check = new DependencyCheck - { - // No RegistryValue and no view pin, matching the .NET 10 preset: the versions are - // the value names, and the record lives under the 32-bit view that the default - // both-views probe reaches. - RegistryKey = - $@"SOFTWARE\dotnet\Setup\InstalledVersions\{registryArch}\sharedfx\Microsoft.WindowsDesktop.App" - }, - Fix = new DependencyFix - { - DownloadUrl = installerUrl, - AutoInstall = new RunInstallerAutoInstall - { - Sha256 = sha256, - // Microsoft's own switches. /norestart matters: the installer will otherwise - // reboot the machine out from under someone mid-install. - Args = ["/install", "/quiet", "/norestart"], - NeedsAdmin = true - } - } - } - }; + DisplayName = preset.DisplayName, + Description = preset.Description, + Build = preset.ToDependency + }) + .ToArray(); } diff --git a/src/AccessibilityModManager.AuthorTool/ViewModels/IndexEditorViewModel.cs b/src/AccessibilityModManager.AuthorTool/ViewModels/IndexEditorViewModel.cs index 934dbd6..ad3d438 100644 --- a/src/AccessibilityModManager.AuthorTool/ViewModels/IndexEditorViewModel.cs +++ b/src/AccessibilityModManager.AuthorTool/ViewModels/IndexEditorViewModel.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using AccessibilityModManager.Authoring.Workflows; using AccessibilityModManager.AuthorTool.Services; using AccessibilityModManager.Core.Models; using AccessibilityModManager.Infrastructure.CatalogClaims; @@ -47,6 +48,7 @@ public sealed partial class IndexEditorViewModel : ObservableObject private readonly ProjectReconciler _reconciler; private readonly IndexPublishCoordinator _publishCoordinator; private readonly Action _showClaimSigningDialog; + private readonly AuthoringWorkflowFacade _workflows; private PluginRepoIndex _index; private bool _suppressDirty; @@ -193,6 +195,7 @@ public IndexEditorViewModel( IndexPublishCoordinator publishCoordinator, GitHubIndexPublisher gitHubPublisher, UnsignedPublishGate unsignedGate, + AuthoringWorkflowFacade workflows, Action showClaimSigningDialog) { _gitHubPublisher = gitHubPublisher; @@ -217,6 +220,7 @@ public IndexEditorViewModel( _registryChecker = registryChecker; _reconciler = reconciler; _publishCoordinator = publishCoordinator; + _workflows = workflows; _showClaimSigningDialog = showClaimSigningDialog; _patreon.StateChanged += OnPatreonStateChanged; @@ -1149,8 +1153,17 @@ private async Task BreakPublishLockAsync() ServerUploadService.RemoteLock found; try { - found = await _serverUploadService.ReadPublishLockAsync( - cfg, _index.PluginId, CancellationToken.None); + var inspected = await _workflows.InspectIndexLockAsync( + _index.PluginId, + CancellationToken.None); + if (inspected.ErrorKind != WorkflowErrorKind.None) + { + _showInfoDialog( + "Couldn't check the publish lock", + string.Join("\n\n", inspected.Messages)); + return; + } + found = inspected.Value; } catch (Exception ex) { @@ -1168,6 +1181,15 @@ private async Task BreakPublishLockAsync() return; } + if (string.IsNullOrWhiteSpace(found.Fingerprint)) + { + _showInfoDialog( + "Couldn't identify the publish lock", + "The lock exists, but it has no stable fingerprint. It was left alone because " + + "the tool cannot prove it is still the same lock after confirmation."); + return; + } + var whoHasIt = found.Body is not null ? $"It is held by {found.Body.Describe()}." : "There is a lock file there, but its contents can't be read, so who holds it is unknown."; @@ -1189,8 +1211,23 @@ private async Task BreakPublishLockAsync() // The lock the author just read about is named, so a different one that has since // been taken at the same path is left alone rather than deleted on the strength of // a question that was about something else. - cleared = await _serverUploadService.BreakPublishLockAsync( - cfg, _index.PluginId, found.Fingerprint, CancellationToken.None); + var result = await _workflows.BreakIndexLockAsync( + _index.PluginId, + found.Fingerprint!, + confirmed: true, + CancellationToken.None); + if (result.ErrorKind != WorkflowErrorKind.None) + { + if (result.Status == "publishLockChanged") + { + _showInfoDialog("The lock changed", string.Join("\n\n", result.Messages)); + return; + } + + _showInfoDialog("Couldn't clear the publish lock", string.Join("\n\n", result.Messages)); + return; + } + cleared = result.Value; } catch (Exception ex) { @@ -1646,7 +1683,7 @@ await _serverUploadService.PublishIndexAsync( /// Writes index.json to disk, including all in-progress game edits. Returns false on /// failure (caller surfaces nothing extra; the dialog already showed an error). /// - private bool TrySaveIndexToDisk() + private async Task TrySaveIndexToDiskAsync() { try { @@ -1658,9 +1695,21 @@ private bool TrySaveIndexToDisk() GeneratedAt = DateTime.UtcNow, Games = _index.Games, ReleasesByGameId = _index.ReleasesByGameId, - Author = _index.Author + Author = _index.Author, + DependencyPresets = _index.DependencyPresets }; - _indexFileService.Save(_projectPath, updated); + + var saved = await _workflows.SaveIndexAsync( + _projectPath, + updated, + dryRun: false, + CancellationToken.None); + if (saved.ErrorKind != WorkflowErrorKind.None) + { + _showInfoDialog("Save failed", string.Join(Environment.NewLine, saved.Messages)); + return false; + } + _index = updated; HasUnsavedChanges = false; return true; @@ -1700,9 +1749,113 @@ private async Task PublishToDestinationAsync(string commitMessage, bool co OnPropertyChanged(nameof(PublishButtonName)); } - return destination == PublishDestination.GitHub - ? await PublishIndexToGitHubAsync(commitMessage, confirmFirst) - : await PublishIndexToServerAsync(commitMessage, confirmFirst); + var request = new IndexPublishRequest( + _projectPath, + _index, + destination, + commitMessage, + DryRun: false); + WorkflowResult preview; + try + { + preview = await _workflows.PreviewIndexPublicationAsync(request, CancellationToken.None); + } + catch (Exception ex) + { + _logger.Error(ex, "Index publication preview failed"); + _showInfoDialog("Can't publish index", ex.Message); + StatusMessage = "Saved locally. Nothing was published."; + return false; + } + + if (preview.ErrorKind != WorkflowErrorKind.None || preview.Value is null) + { + _showInfoDialog("Can't publish index", string.Join("\n\n", preview.Messages)); + StatusMessage = "Saved locally. Nothing was published."; + return false; + } + + if (confirmFirst) + { + var changes = preview.Value.CatalogChanges.Count == 0 + ? "No catalog entry changes were detected." + : string.Join("\n", preview.Value.CatalogChanges.Select(change => $"- {change}")); + if (!_confirmDialog( + "Publish index", + $"This publishes index.json for '{_index.PluginId}' to:\n\n" + + $"{preview.Value.DestinationDescription}\n\n" + + $"Change: {preview.Value.CommitMessage}\n\n{changes}\n\nProceed?")) + { + StatusMessage = "Saved locally. Publish index when ready."; + return false; + } + } + + if (!TryBeginServerOperation()) + { + _showInfoDialog("Busy", AnotherOperationInFlight); + return false; + } + + try + { + StatusMessage = destination == PublishDestination.GitHub + ? "Publishing index to GitHub..." + : "Publishing index to the server..."; + var result = await _workflows.PublishIndexAsync( + request, + confirmed: true, + CancellationToken.None); + if (result.ErrorKind != WorkflowErrorKind.None || result.Value is null) + { + var phases = result.CompletedPhases ?? []; + var title = phases.Contains("indexPublished", StringComparer.Ordinal) + ? "Published, but verification did not finish" + : phases.Contains("indexCommitted", StringComparer.Ordinal) + ? "Committed, but not pushed" + : "Publish failed"; + _showInfoDialog(title, string.Join("\n\n", result.Messages)); + StatusMessage = phases.Contains("indexPublished", StringComparer.Ordinal) + ? "The index may be live, but verification did not finish. Publish again to reconcile it." + : phases.Contains("indexCommitted", StringComparer.Ordinal) + ? "Committed locally, but not pushed — managers can't see it." + : "Saved locally. Nothing was published."; + return false; + } + + try + { + _liveIndexAtLoad = File.ReadAllBytes(Path.Combine(_projectPath, "index.json")); + } + catch (Exception ex) + { + _logger.Warning(ex, "Couldn't refresh the live-index baseline after publication"); + } + + StatusMessage = $"Published to {result.Value.DestinationDescription}."; + if (destination == PublishDestination.GitHub && IsListedInRegistry is false) + { + _showInfoDialog( + "Pushed, but not listed yet", + "index.json is live, but the manager only reads catalogs listed in the signed " + + "registry. Add this exact index address to the registry before expecting users " + + "to see it."); + } + return true; + } + catch (Exception ex) + { + _logger.Error(ex, "Index publication stopped unexpectedly"); + _showInfoDialog( + "Publish stopped", + $"{ex.Message}\n\nChoose Publish index again. It will reconcile the local and live state before changing anything."); + StatusMessage = "Saved locally. Publication did not finish."; + return false; + } + finally + { + EndServerOperation(); + } } /// @@ -1860,7 +2013,7 @@ private PublishDestination AskWhereThisPublishes() private async Task PublishAfterReleaseChangeAsync( string commitMessage, PendingGateChange? gateChange = null) { - if (!TrySaveIndexToDisk()) + if (!await TrySaveIndexToDiskAsync()) return; var catalogMatches = await PublishToDestinationAsync(commitMessage, confirmFirst: true); diff --git a/src/AccessibilityModManager.AuthorTool/ViewModels/ReleaseDialogViewModel.cs b/src/AccessibilityModManager.AuthorTool/ViewModels/ReleaseDialogViewModel.cs index 7789b87..cd04657 100644 --- a/src/AccessibilityModManager.AuthorTool/ViewModels/ReleaseDialogViewModel.cs +++ b/src/AccessibilityModManager.AuthorTool/ViewModels/ReleaseDialogViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.IO; using System.Security.Cryptography; +using AccessibilityModManager.Authoring.Workflows; using AccessibilityModManager.AuthorTool.Services; using AccessibilityModManager.Core.Models; using AccessibilityModManager.Infrastructure.Patreon; @@ -355,6 +356,7 @@ public string PatreonSignedInAsText public PendingGateChange? GateChange { get; private set; } private readonly ServerUploadService _serverUpload; + private readonly AuthoringWorkflowFacade _workflows; public ReleaseDialogViewModel( string gameId, @@ -373,7 +375,8 @@ public ReleaseDialogViewModel( Func confirmDialog, Func browseForFile, Func showBuildPackageDialog, - ModRelease? existingRelease = null) + ModRelease? existingRelease, + AuthoringWorkflowFacade workflows) { _gameId = gameId; GameDisplayName = gameDisplayName; @@ -386,6 +389,7 @@ public ReleaseDialogViewModel( _configService = configService; _patreonAuthor = patreonAuthor; _serverUpload = serverUpload; + _workflows = workflows ?? throw new ArgumentNullException(nameof(workflows)); _logger = logger; _showInfoDialog = showInfoDialog; _confirmDialog = confirmDialog; @@ -846,11 +850,29 @@ private async Task StageVerifyAndPublishAsync(PatreonGate? gate, PublishDe // Copying and hashing a wrapped ZIP is real I/O — off the UI thread, or the window // freezes and takes the screen reader's feedback with it. - StagedPackage staged; + PreparedRelease staged; try { StatusMessage = $"Preparing {sourceName}..."; - staged = await Task.Run(() => StagedPackage.Create(LocalZipPath!, AssetFileName)); + var stagedResult = await _workflows.StageReleasePackageAsync( + new PackageStageRequest( + _pluginId, + _gameId, + version, + LocalZipPath!, + AssetFileName), + CancellationToken.None); + if (stagedResult.ErrorKind != WorkflowErrorKind.None || stagedResult.Value is null) + { + _showInfoDialog( + stagedResult.Status == "packageValidationFailed" + ? "The package won't install" + : "Can't read the wrapped ZIP", + string.Join("\n\n", stagedResult.Messages)); + return false; + } + + staged = stagedResult.Value; } catch (Exception ex) { @@ -859,24 +881,13 @@ private async Task StageVerifyAndPublishAsync(PatreonGate? gate, PublishDe return false; } - using (staged) + await using (staged) { - StatusMessage = $"Checking {staged.FileName}..."; - var report = await Task.Run(() => PluginPackageValidation.Validate( - staged.Stream, _pluginId, _gameId, version, _logger)); - if (!report.IsValid) - { - _showInfoDialog("The package won't install", - "The manager would refuse this ZIP on the user's machine:\n\n" + - string.Join("\n\n", report.Errors)); - return false; - } - // The authoritative identity: the hash comes off the held handle, after the manifest // check, right before the bytes are published — and it is recorded here so the saved // release describes THIS package even if the form is edited later. Sha256 = staged.Sha256; - AssetFileName = staged.FileName; + AssetFileName = staged.Preview.AssetFileName; _published = new PublishedIdentity(version, staged.Sha256); var published = destination switch @@ -888,7 +899,7 @@ private async Task StageVerifyAndPublishAsync(PatreonGate? gate, PublishDe if (!published) return false; if (destination == PublishDestination.None) - StatusMessage = $"Checked {staged.FileName}. SHA256: {staged.Sha256}"; + StatusMessage = $"Checked {staged.Preview.AssetFileName}. SHA256: {staged.Sha256}"; return true; } @@ -900,7 +911,7 @@ private async Task StageVerifyAndPublishAsync(PatreonGate? gate, PublishDe /// version that is already live with different bytes, and asks first when saving a release /// as public would strip a Patreon gate that's currently in force. /// - private async Task PublishToServerAsync(StagedPackage staged, string version, PatreonGate? gate) + private async Task PublishToServerAsync(PreparedRelease staged, string version, PatreonGate? gate) { var serverCfg = _configService.GetServerUploadConfig(); if (serverCfg == null) @@ -916,7 +927,7 @@ private async Task PublishToServerAsync(StagedPackage staged, string versi { StatusMessage = $"Checking what's already published on {serverCfg.Host}..."; state = await _serverUpload.ProbeReleaseAsync( - serverCfg, _gameId, version, staged.FileName, staged.Stream, staged.Sha256, + serverCfg, _gameId, version, staged.Preview.AssetFileName, staged.Stream, staged.Sha256, CancellationToken.None); } catch (Exception ex) @@ -931,7 +942,7 @@ private async Task PublishToServerAsync(StagedPackage staged, string versi { _showInfoDialog("That version folder already holds another file", $"Version {version} on {serverCfg.Host} already contains {string.Join(", ", state.OtherAssets)}, " + - $"which isn't the file you're publishing ({staged.FileName}).\n\n" + + $"which isn't the file you're publishing ({staged.Preview.AssetFileName}).\n\n" + "A version folder holds exactly one package, because the Patreon tier lock applies to the " + "whole folder — a second file there would ride this release's lock, or lose its own when " + "this one goes public.\n\n" + @@ -966,11 +977,11 @@ private async Task PublishToServerAsync(StagedPackage staged, string versi try { StatusMessage = state.PackageMatches - ? $"Confirming {staged.FileName} on {serverCfg.Host}..." - : $"Uploading {staged.FileName} to {serverCfg.Host}..."; + ? $"Confirming {staged.Preview.AssetFileName} on {serverCfg.Host}..." + : $"Uploading {staged.Preview.AssetFileName} to {serverCfg.Host}..."; var outcome = await _serverUpload.PublishReleaseAsync( - serverCfg, _gameId, version, staged.FileName, staged.Stream, staged.Sha256, + serverCfg, _gameId, version, staged.Preview.AssetFileName, staged.Stream, staged.Sha256, gate, CancellationToken.None); if (gate != null) @@ -1125,7 +1136,7 @@ _existingGate is null || /// release when the tag is new, or replaces the asset on an existing tag. Uploads the /// staged copy, so what lands on the release is the file that was hashed and checked. /// - private async Task PublishToGitHubAsync(StagedPackage staged, string version) + private async Task PublishToGitHubAsync(PreparedRelease staged, string version) { if (string.IsNullOrWhiteSpace(SourceRepo)) { @@ -1143,7 +1154,7 @@ private async Task PublishToGitHubAsync(StagedPackage staged, string versi } var tag = string.IsNullOrWhiteSpace(TagName) ? $"v{version}" : TagName!.Trim(); - var assetUrl = GitHubService.BuildAssetUrl(SourceRepo, tag, staged.FileName); + var assetUrl = GitHubService.BuildAssetUrl(SourceRepo, tag, staged.Preview.AssetFileName); StatusMessage = $"Checking what's already published at {SourceRepo} {tag}..."; var existingReleases = await _gitHubService.ListReleasesAsync(SourceRepo); @@ -1174,7 +1185,7 @@ private async Task PublishToGitHubAsync(StagedPackage staged, string versi if (!string.Equals(publishedSha, staged.Sha256, StringComparison.OrdinalIgnoreCase)) { _showInfoDialog("That release already has this file", - $"{SourceRepo} {tag} already publishes {staged.FileName}, and this ZIP is a different " + + $"{SourceRepo} {tag} already publishes {staged.Preview.AssetFileName}, and this ZIP is a different " + "file. Replacing it would break the download for anyone whose catalog still lists the " + "old fingerprint.\n\nBump the version and publish that instead. Nothing was uploaded."); return false; @@ -1187,7 +1198,7 @@ private async Task PublishToGitHubAsync(StagedPackage staged, string versi } } - StatusMessage = $"Uploading {staged.FileName} to {SourceRepo} {tag}..."; + StatusMessage = $"Uploading {staged.Preview.AssetFileName} to {SourceRepo} {tag}..."; var notes = string.IsNullOrWhiteSpace(ReleaseNotes) ? $"Release {tag} for the Accessibility Mod Manager." @@ -1196,7 +1207,7 @@ private async Task PublishToGitHubAsync(StagedPackage staged, string versi ProcessResult result; if (hasTag) { - result = await _gitHubService.UploadReleaseAssetAsync(SourceRepo, tag, staged.Path, clobber: true); + result = await _gitHubService.UploadReleaseAssetAsync(SourceRepo, tag, staged.StagedPath, clobber: true); if (result.Success && !string.IsNullOrWhiteSpace(ReleaseNotes)) { // Tag already existed; refresh its release notes too. @@ -1211,7 +1222,7 @@ private async Task PublishToGitHubAsync(StagedPackage staged, string versi SourceRepo, tag, title: tag, notes: notes, - new[] { staged.Path }); + new[] { staged.StagedPath }); } if (!result.Success) @@ -1271,88 +1282,6 @@ private enum PublishedProbe } } - /// - /// A private copy of the wrapped ZIP, named the way it will be published, held open for as - /// long as the publish takes. The copy lives in our own temp folder rather than beside the - /// author's build output: renaming in place used to overwrite whatever file already had - /// that name in their folder, and a build tool writing to the original mid-publish would - /// have made the published hash a lie. Deleting the folder is what disposal is for. - /// - private sealed class StagedPackage : IDisposable - { - private readonly string _tempDir; - - public FileStream Stream { get; } - public string Path { get; } - public string FileName { get; } - public string Sha256 { get; } - - private StagedPackage(string tempDir, string path, string fileName, FileStream stream, string sha256) - { - _tempDir = tempDir; - Path = path; - FileName = fileName; - Stream = stream; - Sha256 = sha256; - } - - public static StagedPackage Create(string sourcePath, string? assetFileName) - { - if (!File.Exists(sourcePath)) - throw new FileNotFoundException($"The wrapped ZIP isn't there any more: {sourcePath}", sourcePath); - - // The published filename becomes a path segment locally and remotely, so it has to - // be a plain file name (audit finding 38c). - var fileName = PathSafety.EnsureLeafFileName( - string.IsNullOrWhiteSpace(assetFileName) ? System.IO.Path.GetFileName(sourcePath) : assetFileName, - "Asset filename"); - - var tempDir = System.IO.Path.Combine( - System.IO.Path.GetTempPath(), "AccessibilityModManager.AuthorTool", "publish", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - - try - { - var stagedPath = System.IO.Path.Combine(tempDir, fileName); - File.Copy(sourcePath, stagedPath); - - // FileShare.Read: readers (the gh CLI) are fine, writers are locked out for the - // lifetime of the publish. - var stream = new FileStream( - stagedPath, FileMode.Open, FileAccess.Read, FileShare.Read); - try - { - var sha = Convert.ToHexStringLower(SHA256.HashData(stream)); - stream.Position = 0; - return new StagedPackage(tempDir, stagedPath, fileName, stream, sha); - } - catch - { - stream.Dispose(); - throw; - } - } - catch - { - TryDelete(tempDir); - throw; - } - } - - public void Dispose() - { - Stream.Dispose(); - TryDelete(_tempDir); - } - - private static void TryDelete(string dir) - { - try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } - catch (IOException) { /* temp folder; the OS cleans up */ } - catch (UnauthorizedAccessException) { } - } - } - [RelayCommand] private void Cancel() { diff --git a/src/AccessibilityModManager.Authoring/AccessibilityModManager.Authoring.csproj b/src/AccessibilityModManager.Authoring/AccessibilityModManager.Authoring.csproj new file mode 100644 index 0000000..0b73b73 --- /dev/null +++ b/src/AccessibilityModManager.Authoring/AccessibilityModManager.Authoring.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + net10.0-windows + enable + enable + AccessibilityModManager.Authoring + + + + $(DefineConstants);REGISTRY_ADMIN + + + diff --git a/src/AccessibilityModManager.AuthorTool/Services/AuthorConfig.cs b/src/AccessibilityModManager.Authoring/Services/AuthorConfig.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/AuthorConfig.cs rename to src/AccessibilityModManager.Authoring/Services/AuthorConfig.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/AuthorConfigService.cs b/src/AccessibilityModManager.Authoring/Services/AuthorConfigService.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/AuthorConfigService.cs rename to src/AccessibilityModManager.Authoring/Services/AuthorConfigService.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/ClaimSigningKeyStore.cs b/src/AccessibilityModManager.Authoring/Services/ClaimSigningKeyStore.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/ClaimSigningKeyStore.cs rename to src/AccessibilityModManager.Authoring/Services/ClaimSigningKeyStore.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/GitHubIndexPublisher.cs b/src/AccessibilityModManager.Authoring/Services/GitHubIndexPublisher.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/GitHubIndexPublisher.cs rename to src/AccessibilityModManager.Authoring/Services/GitHubIndexPublisher.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/GitHubService.cs b/src/AccessibilityModManager.Authoring/Services/GitHubService.cs similarity index 90% rename from src/AccessibilityModManager.AuthorTool/Services/GitHubService.cs rename to src/AccessibilityModManager.Authoring/Services/GitHubService.cs index 3e18077..18505a3 100644 --- a/src/AccessibilityModManager.AuthorTool/Services/GitHubService.cs +++ b/src/AccessibilityModManager.Authoring/Services/GitHubService.cs @@ -6,12 +6,39 @@ namespace AccessibilityModManager.AuthorTool.Services; public sealed record GitHubRepo(string NameWithOwner, string Description, string Url); public sealed record GitHubRelease(string TagName, string Name, bool IsDraft, bool IsPrerelease); +public interface IGitHubService +{ + Task IsAvailableAsync(CancellationToken ct = default); + Task IsAuthenticatedAsync(CancellationToken ct = default); + Task> ListReposAsync(int limit = 100, CancellationToken ct = default); + Task IsRepoPrivateAsync(string nameWithOwner, CancellationToken ct = default); + Task> ListReleasesAsync(string repo, int limit = 30, CancellationToken ct = default); + Task CreateReleaseAsync( + string repo, + string tagName, + string title, + string? notes, + IEnumerable assetPaths, + CancellationToken ct = default); + Task EditReleaseNotesAsync( + string repo, + string tagName, + string notes, + CancellationToken ct = default); + Task UploadReleaseAssetAsync( + string repo, + string tagName, + string assetPath, + bool clobber, + CancellationToken ct = default); +} + /// /// Wraps the gh CLI. Authentication is delegated entirely to gh auth login — /// we do not handle tokens ourselves. If gh is missing or not authed, the relevant /// methods surface a clear error. /// -public sealed class GitHubService +public sealed class GitHubService : IGitHubService { private static readonly JsonSerializerOptions JsonOptions = new() { diff --git a/src/AccessibilityModManager.AuthorTool/Services/GitService.cs b/src/AccessibilityModManager.Authoring/Services/GitService.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/GitService.cs rename to src/AccessibilityModManager.Authoring/Services/GitService.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/IndexFileService.cs b/src/AccessibilityModManager.Authoring/Services/IndexFileService.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/IndexFileService.cs rename to src/AccessibilityModManager.Authoring/Services/IndexFileService.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/IndexProofService.cs b/src/AccessibilityModManager.Authoring/Services/IndexProofService.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/IndexProofService.cs rename to src/AccessibilityModManager.Authoring/Services/IndexProofService.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/IndexPublishCoordinator.cs b/src/AccessibilityModManager.Authoring/Services/IndexPublishCoordinator.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/IndexPublishCoordinator.cs rename to src/AccessibilityModManager.Authoring/Services/IndexPublishCoordinator.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/LocalIndexAdoption.cs b/src/AccessibilityModManager.Authoring/Services/LocalIndexAdoption.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/LocalIndexAdoption.cs rename to src/AccessibilityModManager.Authoring/Services/LocalIndexAdoption.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/ManifestBuilderService.cs b/src/AccessibilityModManager.Authoring/Services/ManifestBuilderService.cs similarity index 91% rename from src/AccessibilityModManager.AuthorTool/Services/ManifestBuilderService.cs rename to src/AccessibilityModManager.Authoring/Services/ManifestBuilderService.cs index 2749afc..300850e 100644 --- a/src/AccessibilityModManager.AuthorTool/Services/ManifestBuilderService.cs +++ b/src/AccessibilityModManager.Authoring/Services/ManifestBuilderService.cs @@ -63,36 +63,17 @@ public async Task BuildPackageAsync( LifecycleScriptInputs? scripts = null, CancellationToken ct = default) { - if (!Directory.Exists(sourceFolder)) - throw new DirectoryNotFoundException($"Source folder not found: {sourceFolder}"); - - sourceFolder = Path.GetFullPath(sourceFolder); + ct.ThrowIfCancellationRequested(); + sourceFolder = ValidateBuildInputs(sourceFolder, scripts); var topLevelEntries = Directory .EnumerateFileSystemEntries(sourceFolder, "*", SearchOption.TopDirectoryOnly) .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) .ToList(); - // A pure script-only mod is a legitimate shape (e.g. a release that just toggles a - // registry key). Require non-empty content only when there are no scripts to run. - var hasAnyScript = - scripts?.PreInstall is not null || - scripts?.PostInstall is not null || - scripts?.PostUninstall is not null; - if (topLevelEntries.Count == 0 && !hasAnyScript) - throw new InvalidOperationException( - "Source folder is empty and no lifecycle script is enabled. Put your mod files in there first (e.g. version.dll, MelonLoader/, Mods/), or enable a script on the Scripts tab."); - - // Verify each declared script can be located: either via the absolute path the author - // picked with Browse, or — failing that — under the source folder. Catches typos and - // missing files before the user uploads, since the manager's installer rejects - // manifests whose scripts aren't in the wrapped ZIP. var preInstall = scripts?.PreInstall; var postInstall = scripts?.PostInstall; var postUninstall = scripts?.PostUninstall; - ValidateScriptIsBundled(preInstall, scripts?.PreInstallSourcePath, sourceFolder, "Pre-install"); - ValidateScriptIsBundled(postInstall, scripts?.PostInstallSourcePath, sourceFolder, "Post-install"); - ValidateScriptIsBundled(postUninstall, scripts?.PostUninstallSourcePath, sourceFolder, "Post-uninstall"); // Action source paths are relative to the staging dir's files/ subfolder — the // InstallerEngine passes /files as the executor's base path. Prepending @@ -206,6 +187,51 @@ await TryAddScriptFromAbsoluteAsync(zip, postUninstall, scripts?.PostUninstallSo return new BuiltPackage(outputZipPath, fileCount, totalBytes); } + /// + /// Performs every source-side check used by without creating + /// an output directory or ZIP. The CLI uses this for a genuinely non-writing dry run. + /// + public static string ValidateBuildInputs( + string sourceFolder, + LifecycleScriptInputs? scripts = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceFolder); + if (!Directory.Exists(sourceFolder)) + throw new DirectoryNotFoundException($"Source folder not found: {sourceFolder}"); + + var normalizedSource = Path.GetFullPath(sourceFolder); + var hasContent = Directory + .EnumerateFileSystemEntries(normalizedSource, "*", SearchOption.TopDirectoryOnly) + .Any(); + var hasAnyScript = + scripts?.PreInstall is not null || + scripts?.PostInstall is not null || + scripts?.PostUninstall is not null; + if (!hasContent && !hasAnyScript) + { + throw new InvalidOperationException( + "Source folder is empty and no lifecycle script is enabled. Put your mod files in there first (e.g. version.dll, MelonLoader/, Mods/), or enable a script on the Scripts tab."); + } + + ValidateScriptIsBundled( + scripts?.PreInstall, + scripts?.PreInstallSourcePath, + normalizedSource, + "Pre-install"); + ValidateScriptIsBundled( + scripts?.PostInstall, + scripts?.PostInstallSourcePath, + normalizedSource, + "Post-install"); + ValidateScriptIsBundled( + scripts?.PostUninstall, + scripts?.PostUninstallSourcePath, + normalizedSource, + "Post-uninstall"); + + return normalizedSource; + } + /// /// Adds a mapping the script's in-package path to the game /// folder root when the author opted into . diff --git a/src/AccessibilityModManager.AuthorTool/Services/PatreonAuthorService.cs b/src/AccessibilityModManager.Authoring/Services/PatreonAuthorService.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/PatreonAuthorService.cs rename to src/AccessibilityModManager.Authoring/Services/PatreonAuthorService.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/ProcessRunner.cs b/src/AccessibilityModManager.Authoring/Services/ProcessRunner.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/ProcessRunner.cs rename to src/AccessibilityModManager.Authoring/Services/ProcessRunner.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/ProjectReconciler.cs b/src/AccessibilityModManager.Authoring/Services/ProjectReconciler.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/ProjectReconciler.cs rename to src/AccessibilityModManager.Authoring/Services/ProjectReconciler.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/PublishLock.cs b/src/AccessibilityModManager.Authoring/Services/PublishLock.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/PublishLock.cs rename to src/AccessibilityModManager.Authoring/Services/PublishLock.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/PublishPresentation.cs b/src/AccessibilityModManager.Authoring/Services/PublishPresentation.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/PublishPresentation.cs rename to src/AccessibilityModManager.Authoring/Services/PublishPresentation.cs diff --git a/src/AccessibilityModManager.Authoring/Services/PublishedAssetProbe.cs b/src/AccessibilityModManager.Authoring/Services/PublishedAssetProbe.cs new file mode 100644 index 0000000..828a255 --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Services/PublishedAssetProbe.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Security.Cryptography; +using Serilog; + +namespace AccessibilityModManager.AuthorTool.Services; + +public enum PublishedAssetStatus +{ + Found, + Absent, + Unreadable +} + +public sealed record PublishedAssetState(PublishedAssetStatus Status, string? Sha256); + +public interface IPublishedAssetProbe +{ + Task ProbeAsync(Uri url, CancellationToken ct = default); +} + +public sealed class PublishedAssetProbe : IPublishedAssetProbe +{ + private static readonly HttpClient Http = new() + { + Timeout = TimeSpan.FromMinutes(10) + }; + + private readonly ILogger _logger; + + public PublishedAssetProbe(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ProbeAsync(Uri url, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(url); + if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Published asset probes require an https:// URL."); + + try + { + var separator = string.IsNullOrEmpty(url.Query) ? "?" : "&"; + var cacheBusted = new Uri( + url.AbsoluteUri + separator + "_=" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + using var response = await Http.GetAsync( + cacheBusted, + HttpCompletionOption.ResponseHeadersRead, + ct); + + if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone) + return new PublishedAssetState(PublishedAssetStatus.Absent, null); + if (!response.IsSuccessStatusCode) + { + _logger.Warning("Reading {Url} returned {Status}", url, response.StatusCode); + return new PublishedAssetState(PublishedAssetStatus.Unreadable, null); + } + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + var sha = Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, ct)); + return new PublishedAssetState(PublishedAssetStatus.Found, sha); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.Warning(ex, "Couldn't read the published asset at {Url}", url); + return new PublishedAssetState(PublishedAssetStatus.Unreadable, null); + } + } +} diff --git a/src/AccessibilityModManager.AuthorTool/Services/PublisherHeadStore.cs b/src/AccessibilityModManager.Authoring/Services/PublisherHeadStore.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/PublisherHeadStore.cs rename to src/AccessibilityModManager.Authoring/Services/PublisherHeadStore.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/RegistryMembershipChecker.cs b/src/AccessibilityModManager.Authoring/Services/RegistryMembershipChecker.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/RegistryMembershipChecker.cs rename to src/AccessibilityModManager.Authoring/Services/RegistryMembershipChecker.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/ServerSelfTest.cs b/src/AccessibilityModManager.Authoring/Services/ServerSelfTest.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/ServerSelfTest.cs rename to src/AccessibilityModManager.Authoring/Services/ServerSelfTest.cs diff --git a/src/AccessibilityModManager.AuthorTool/Services/ServerUploadService.cs b/src/AccessibilityModManager.Authoring/Services/ServerUploadService.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/ServerUploadService.cs rename to src/AccessibilityModManager.Authoring/Services/ServerUploadService.cs diff --git a/src/AccessibilityModManager.Authoring/Services/Sha256HashService.cs b/src/AccessibilityModManager.Authoring/Services/Sha256HashService.cs new file mode 100644 index 0000000..123a10b --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Services/Sha256HashService.cs @@ -0,0 +1,35 @@ +using System.IO; +using System.Security.Cryptography; + +namespace AccessibilityModManager.AuthorTool.Services; + +public sealed class Sha256HashService +{ + public async Task ComputeAsync(string filePath, CancellationToken ct = default) + { + await using var stream = File.OpenRead(filePath); + return await ComputeAsync(stream, ct); + } + + public async Task ComputeAsync(Stream stream, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.CanRead) + throw new InvalidOperationException("The SHA256 input stream isn't readable."); + + var originalPosition = stream.CanSeek ? stream.Position : (long?)null; + if (stream.CanSeek) + stream.Position = 0; + + try + { + var hash = await SHA256.HashDataAsync(stream, ct); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + finally + { + if (originalPosition.HasValue) + stream.Position = originalPosition.Value; + } + } +} diff --git a/src/AccessibilityModManager.AuthorTool/Services/UnsignedPublishGate.cs b/src/AccessibilityModManager.Authoring/Services/UnsignedPublishGate.cs similarity index 100% rename from src/AccessibilityModManager.AuthorTool/Services/UnsignedPublishGate.cs rename to src/AccessibilityModManager.Authoring/Services/UnsignedPublishGate.cs diff --git a/src/AccessibilityModManager.Authoring/Workflows/AuthorProjectContext.cs b/src/AccessibilityModManager.Authoring/Workflows/AuthorProjectContext.cs new file mode 100644 index 0000000..d38934e --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/AuthorProjectContext.cs @@ -0,0 +1,85 @@ +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using AccessibilityModManager.Infrastructure.Security; + +namespace AccessibilityModManager.Authoring.Workflows; + +public sealed record ResolvedAuthorProject(string ProjectPath, PluginRepoIndex Index); + +public sealed class AuthorProjectContext +{ + private const string LockFileName = ".amm-author.lock"; + + private readonly AuthorConfigService _configService; + private readonly IndexFileService _indexFiles; + + public AuthorProjectContext( + AuthorConfigService configService, + IndexFileService indexFiles) + { + _configService = configService ?? throw new ArgumentNullException(nameof(configService)); + _indexFiles = indexFiles ?? throw new ArgumentNullException(nameof(indexFiles)); + } + + public Task ResolveAsync( + string? explicitPath, + string currentDirectory, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + if (!string.IsNullOrWhiteSpace(explicitPath)) + { + return Task.FromResult(LoadResolvedProject(Path.GetFullPath(explicitPath))); + } + + if (IsProjectDirectory(currentDirectory)) + { + return Task.FromResult(LoadResolvedProject(Path.GetFullPath(currentDirectory))); + } + + var savedPath = _configService.Load().LastOpenedProjectPath; + if (string.IsNullOrWhiteSpace(savedPath)) + { + throw new InvalidOperationException( + "No author project could be resolved from --project, the current directory, or the saved last-opened project."); + } + + return Task.FromResult(LoadResolvedProject(Path.GetFullPath(savedPath))); + } + + public async Task AcquireWriteLeaseAsync(string projectPath, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); + + ct.ThrowIfCancellationRequested(); + + var fullProjectPath = Path.GetFullPath(projectPath); + var lockPath = Path.Combine(fullProjectPath, LockFileName); + var lease = await CrossProcessFileLock.AcquireAsync(lockPath, "author project"); + + if (ct.IsCancellationRequested) + { + await lease.DisposeAsync(); + ct.ThrowIfCancellationRequested(); + } + + return lease; + } + + private bool IsProjectDirectory(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + return _indexFiles.Exists(Path.GetFullPath(path)); + } + + private ResolvedAuthorProject LoadResolvedProject(string fullProjectPath) + { + var index = _indexFiles.Load(fullProjectPath); + return new ResolvedAuthorProject(fullProjectPath, index); + } +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/AuthoringBuildFlags.cs b/src/AccessibilityModManager.Authoring/Workflows/AuthoringBuildFlags.cs new file mode 100644 index 0000000..70871f6 --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/AuthoringBuildFlags.cs @@ -0,0 +1,15 @@ +namespace AccessibilityModManager.Authoring.Workflows; + +/// +/// Compile-time authoring capabilities shared by the WPF and command-line front ends. +/// Registry administration is deliberately absent from ordinary builds even though its commands +/// remain discoverable. +/// +public static class AuthoringBuildFlags +{ +#if REGISTRY_ADMIN + public const bool IsRegistryAdmin = true; +#else + public const bool IsRegistryAdmin = false; +#endif +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/AuthoringWorkflowFacade.cs b/src/AccessibilityModManager.Authoring/Workflows/AuthoringWorkflowFacade.cs new file mode 100644 index 0000000..c2ad728 --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/AuthoringWorkflowFacade.cs @@ -0,0 +1,144 @@ +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using AccessibilityModManager.Infrastructure.Services; + +namespace AccessibilityModManager.Authoring.Workflows; + +/// +/// Shared entry point for WPF and command-line authoring surfaces. It keeps project locking and +/// workflow selection out of presentation code, while prompts and announcements remain owned by +/// the caller. +/// +public sealed class AuthoringWorkflowFacade +{ + private readonly AuthorProjectContext _projects; + private readonly PackageWorkflow _packages; + private readonly IReleaseWorkflow _releases; + private readonly IIndexWorkflow _indexes; + private readonly ICompleteReleasePublishWorkflow _completeReleases; + + public AuthoringWorkflowFacade( + AuthorProjectContext projects, + PackageWorkflow packages, + IReleaseWorkflow releases, + IIndexWorkflow indexes, + ICompleteReleasePublishWorkflow completeReleases) + { + _projects = projects ?? throw new ArgumentNullException(nameof(projects)); + _packages = packages ?? throw new ArgumentNullException(nameof(packages)); + _releases = releases ?? throw new ArgumentNullException(nameof(releases)); + _indexes = indexes ?? throw new ArgumentNullException(nameof(indexes)); + _completeReleases = completeReleases ?? throw new ArgumentNullException(nameof(completeReleases)); + } + + public PackageBuildPreview PreviewPackageBuild(PackageBuildRequest request) => + _packages.PreviewBuild(request); + + public Task BuildPackageAsync(PackageBuildRequest request, CancellationToken ct) => + _packages.BuildAsync(request, ct); + + public Task ValidatePackageAsync( + string zipPath, + string pluginId, + string gameId, + string version, + CancellationToken ct) => + _packages.ValidateAsync(zipPath, pluginId, gameId, version, ct); + + public Task> StageReleasePackageAsync( + PackageStageRequest request, + CancellationToken ct) => + _releases.StagePackageAsync(request, ct); + + public Task> PreviewReleaseAsync( + ReleasePublishRequest request, + CancellationToken ct) => + _releases.PreviewAsync(request, ct); + + public Task> PrepareReleaseAsync( + ReleasePublishRequest request, + CancellationToken ct) => + _releases.PrepareAsync(request, ct); + + public Task> PublishReleaseAsync( + PreparedRelease prepared, + ReleasePublishRequest request, + bool confirmed, + CancellationToken ct) => + _releases.PublishAsync(prepared, request, confirmed, ct); + + public IndexValidationReport ValidateIndex(PluginRepoIndex candidate) => + _indexes.Validate(candidate); + + public Task> PreviewIndexPublicationAsync( + IndexPublishRequest request, + CancellationToken ct) => + _indexes.PreviewPublishAsync(request, ct); + + public async Task> ReconcileIndexAsync( + string projectPath, + bool dryRun, + CancellationToken ct) => + await ReconcileIndexAsync(projectPath, dryRun, confirmAdoption: false, ct); + + public async Task> ReconcileIndexAsync( + string projectPath, + bool dryRun, + bool confirmAdoption, + CancellationToken ct) + { + if (dryRun) + return await _indexes.ReconcileAsync(projectPath, dryRun: true, confirmAdoption, ct); + + await using var lease = await _projects.AcquireWriteLeaseAsync(projectPath, ct); + return await _indexes.ReconcileAsync(projectPath, dryRun: false, confirmAdoption, ct); + } + + public async Task> SaveIndexAsync( + string projectPath, + PluginRepoIndex candidate, + bool dryRun, + CancellationToken ct) + { + if (dryRun) + return await _indexes.SaveAsync(projectPath, candidate, dryRun: true, ct); + + await using var lease = await _projects.AcquireWriteLeaseAsync(projectPath, ct); + return await _indexes.SaveAsync(projectPath, candidate, dryRun: false, ct); + } + + public async Task> PublishIndexAsync( + IndexPublishRequest request, + bool confirmed, + CancellationToken ct) + { + if (request.DryRun) + return await _indexes.PublishAsync(request, confirmed, ct); + + await using var lease = await _projects.AcquireWriteLeaseAsync(request.ProjectPath, ct); + return await _indexes.PublishAsync(request, confirmed, ct); + } + + public Task> InspectIndexLockAsync( + string pluginId, + CancellationToken ct) => + _indexes.InspectLockAsync(pluginId, ct); + + public Task> BreakIndexLockAsync( + string pluginId, + string expectedFingerprint, + bool confirmed, + CancellationToken ct) => + _indexes.BreakLockAsync(pluginId, expectedFingerprint, confirmed, ct); + + public Task> PreviewCompleteReleaseAsync( + CompleteReleasePublishRequest request, + CancellationToken ct) => + _completeReleases.PreviewAsync(request, ct); + + public Task> PublishCompleteReleaseAsync( + CompleteReleasePublishRequest request, + bool confirmed, + CancellationToken ct) => + _completeReleases.PublishAsync(request, confirmed, ct); +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/CatalogWorkflow.cs b/src/AccessibilityModManager.Authoring/Workflows/CatalogWorkflow.cs new file mode 100644 index 0000000..e06a9e9 --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/CatalogWorkflow.cs @@ -0,0 +1,593 @@ +using System.Text.Json; +using AccessibilityModManager.Core.Models; +using AccessibilityModManager.Infrastructure.Security; + +namespace AccessibilityModManager.Authoring.Workflows; + +public enum LifecycleSlot +{ + PreInstall, + PostInstall, + PostUninstall +} + +public sealed class CatalogWorkflow +{ + private static readonly StringComparer IdentityComparer = StringComparer.OrdinalIgnoreCase; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public PluginRepoIndex SetAuthor(PluginRepoIndex index, PluginAuthorInfo? author) + { + ArgumentNullException.ThrowIfNull(index); + + var clone = DeepClone(index); + return new PluginRepoIndex + { + PluginId = clone.PluginId, + RepoVersion = clone.RepoVersion, + GeneratedAt = clone.GeneratedAt, + Games = clone.Games, + ReleasesByGameId = clone.ReleasesByGameId, + Author = DeepCloneOrNull(author), + DependencyPresets = clone.DependencyPresets + }; + } + + public PluginRepoIndex CreateProject(string pluginId) + { + PathSafety.EnsureSafeId(pluginId, "Plugin id"); + + return new PluginRepoIndex + { + PluginId = pluginId, + RepoVersion = "1", + GeneratedAt = DateTime.UtcNow, + Games = [], + ReleasesByGameId = new Dictionary>(), + Author = null, + DependencyPresets = [] + }; + } + + public PluginRepoIndex AddGame(PluginRepoIndex index, GameDefinition game) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentNullException.ThrowIfNull(game); + + ValidateGame(game); + EnsureNoGameCollision(index, game.GameId, excludedGameIndex: null, excludedReleaseBucketKey: null, "add"); + + var clone = DeepClone(index); + clone.Games.Add(DeepClone(game)); + clone.ReleasesByGameId[game.GameId] = []; + return clone; + } + + public PluginRepoIndex UpdateGame(PluginRepoIndex index, string currentGameId, GameDefinition replacement) => + UpdateGame(index, currentGameId, replacement, rewriteReleaseGameIds: false); + + public PluginRepoIndex UpdateGame( + PluginRepoIndex index, + string currentGameId, + GameDefinition replacement, + bool rewriteReleaseGameIds) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(currentGameId); + ArgumentNullException.ThrowIfNull(replacement); + + ValidateGame(replacement); + + var clone = DeepClone(index); + var gameIndex = FindUniqueGameIndex(clone.Games, currentGameId); + var existing = clone.Games[gameIndex]; + var sourceReleaseBucketKey = FindUniqueReleaseBucketKey(clone.ReleasesByGameId, currentGameId); + + EnsureNoGameCollision( + clone, + replacement.GameId, + excludedGameIndex: gameIndex, + excludedReleaseBucketKey: sourceReleaseBucketKey, + "rename"); + + var renamed = !string.Equals(existing.GameId, replacement.GameId, StringComparison.Ordinal); + if (renamed) + { + var releases = sourceReleaseBucketKey is null + ? null + : clone.ReleasesByGameId[sourceReleaseBucketKey]; + + if (releases is { Count: > 0 } && !rewriteReleaseGameIds) + { + throw new InvalidOperationException( + $"Can't rename game '{existing.GameId}' to '{replacement.GameId}' because its release bucket contains {releases.Count} release(s). " + + "Their embedded GameId values would no longer match the bucket key. Pass rewriteReleaseGameIds: true to rewrite every embedded release GameId explicitly."); + } + } + + clone.Games[gameIndex] = DeepClone(replacement); + + if (!renamed) + { + return clone; + } + + if (sourceReleaseBucketKey is null) + { + clone.ReleasesByGameId[replacement.GameId] = []; + return clone; + } + + var sourceReleases = clone.ReleasesByGameId[sourceReleaseBucketKey]; + clone.ReleasesByGameId.Remove(sourceReleaseBucketKey); + clone.ReleasesByGameId[replacement.GameId] = rewriteReleaseGameIds + ? [.. sourceReleases.Select(release => RewriteReleaseGameId(release, replacement.GameId))] + : sourceReleases; + + return clone; + } + + public PluginRepoIndex RemoveGame(PluginRepoIndex index, string gameId) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + + var clone = DeepClone(index); + var gameIndex = FindUniqueGameIndex(clone.Games, gameId); + clone.Games.RemoveAt(gameIndex); + + var releaseBucketKey = FindUniqueReleaseBucketKey(clone.ReleasesByGameId, gameId); + if (releaseBucketKey is not null) + { + clone.ReleasesByGameId.Remove(releaseBucketKey); + } + + return clone; + } + + public PluginRepoIndex UpsertDependency(PluginRepoIndex index, string gameId, Dependency dependency) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + ArgumentNullException.ThrowIfNull(dependency); + + ValidateDependency(dependency); + + var clone = DeepClone(index); + var game = GetGame(clone.Games, gameId); + var existingIndex = FindUniqueDependencyIndex(game.Dependencies, dependency.Id, game.GameId, throwWhenMissing: false); + + if (existingIndex >= 0) + { + game.Dependencies[existingIndex] = DeepClone(dependency); + } + else + { + game.Dependencies.Add(DeepClone(dependency)); + } + + return clone; + } + + public PluginRepoIndex RemoveDependency(PluginRepoIndex index, string gameId, string dependencyId) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + ArgumentException.ThrowIfNullOrWhiteSpace(dependencyId); + + var clone = DeepClone(index); + var game = GetGame(clone.Games, gameId); + var dependencyIndex = FindUniqueDependencyIndex(game.Dependencies, dependencyId, game.GameId, throwWhenMissing: true); + game.Dependencies.RemoveAt(dependencyIndex); + return clone; + } + + public PluginRepoIndex SetLifecycleScript(PluginRepoIndex index, string gameId, LifecycleSlot slot, LifecycleScript script) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + ArgumentNullException.ThrowIfNull(script); + + ValidateLifecycleScript(script); + + var clone = DeepClone(index); + var gameIndex = FindUniqueGameIndex(clone.Games, gameId); + clone.Games[gameIndex] = CopyGameWithLifecycle(clone.Games[gameIndex], slot, DeepClone(script)); + return clone; + } + + public PluginRepoIndex ClearLifecycleScript(PluginRepoIndex index, string gameId, LifecycleSlot slot) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + + var clone = DeepClone(index); + var gameIndex = FindUniqueGameIndex(clone.Games, gameId); + clone.Games[gameIndex] = CopyGameWithLifecycle(clone.Games[gameIndex], slot, null); + return clone; + } + + public PluginRepoIndex AddRelease(PluginRepoIndex index, string gameId, ModRelease release) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + ArgumentNullException.ThrowIfNull(release); + + var clone = DeepClone(index); + var game = GetGame(clone.Games, gameId); + ValidateRelease(clone, game, release); + var releases = GetOrCreateReleaseBucket(clone, game.GameId); + var existing = FindUniqueReleaseIndex( + releases, + release.Version, + release.Channel, + game.GameId, + throwWhenMissing: false); + if (existing >= 0) + releases[existing] = DeepClone(release); + else + releases.Add(DeepClone(release)); + return clone; + } + + public PluginRepoIndex EditRelease( + PluginRepoIndex index, + string gameId, + string currentVersion, + string currentChannel, + ModRelease replacement) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + ArgumentException.ThrowIfNullOrWhiteSpace(currentVersion); + ArgumentException.ThrowIfNullOrWhiteSpace(currentChannel); + ArgumentNullException.ThrowIfNull(replacement); + + var clone = DeepClone(index); + var game = GetGame(clone.Games, gameId); + ValidateRelease(clone, game, replacement); + var releases = GetOrCreateReleaseBucket(clone, game.GameId); + var currentIndex = FindUniqueReleaseIndex( + releases, + currentVersion, + currentChannel, + game.GameId, + throwWhenMissing: true); + releases.RemoveAt(currentIndex); + + var collision = FindUniqueReleaseIndex( + releases, + replacement.Version, + replacement.Channel, + game.GameId, + throwWhenMissing: false); + if (collision >= 0) + releases[collision] = DeepClone(replacement); + else + releases.Add(DeepClone(replacement)); + return clone; + } + + public PluginRepoIndex RemoveRelease( + PluginRepoIndex index, + string gameId, + string version, + string channel) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + ArgumentException.ThrowIfNullOrWhiteSpace(channel); + + var clone = DeepClone(index); + var game = GetGame(clone.Games, gameId); + var releases = GetOrCreateReleaseBucket(clone, game.GameId); + var releaseIndex = FindUniqueReleaseIndex( + releases, + version, + channel, + game.GameId, + throwWhenMissing: true); + releases.RemoveAt(releaseIndex); + return clone; + } + + private static GameDefinition GetGame(List games, string gameId) => + games[FindUniqueGameIndex(games, gameId)]; + + private static int FindUniqueGameIndex(List games, string gameId) + { + var matches = games + .Select((game, index) => new { game, index }) + .Where(x => IdentityComparer.Equals(x.game.GameId, gameId)) + .ToList(); + + return matches.Count switch + { + 0 => throw new InvalidOperationException($"Game '{gameId}' was not found."), + > 1 => throw new InvalidOperationException( + $"Multiple games already use id '{gameId}' when compared case-insensitively. Refusing to guess which game to change."), + _ => matches[0].index + }; + } + + private static string? FindUniqueReleaseBucketKey(Dictionary> releasesByGameId, string gameId) + { + var matches = releasesByGameId.Keys + .Where(key => IdentityComparer.Equals(key, gameId)) + .ToList(); + + return matches.Count switch + { + 0 => null, + > 1 => throw new InvalidOperationException( + $"Multiple release buckets already use game id '{gameId}' when compared case-insensitively. Refusing to guess which release bucket belongs to that game."), + _ => matches[0] + }; + } + + private static int FindUniqueDependencyIndex( + List dependencies, + string dependencyId, + string gameId, + bool throwWhenMissing) + { + var matches = dependencies + .Select((dependency, index) => new { dependency, index }) + .Where(x => IdentityComparer.Equals(x.dependency.Id, dependencyId)) + .ToList(); + + return matches.Count switch + { + 0 when throwWhenMissing => throw new InvalidOperationException( + $"Dependency '{dependencyId}' was not found for game '{gameId}'."), + 0 => -1, + > 1 => throw new InvalidOperationException( + $"Game '{gameId}' already contains multiple dependencies with id '{dependencyId}' that differ only by capitalisation. Refusing to guess which dependency to change."), + _ => matches[0].index + }; + } + + private static int FindUniqueReleaseIndex( + List releases, + string version, + string channel, + string gameId, + bool throwWhenMissing) + { + var matches = releases + .Select((release, index) => new { release, index }) + .Where(candidate => + string.Equals(candidate.release.Version, version, StringComparison.Ordinal) && + string.Equals(candidate.release.Channel, channel, StringComparison.Ordinal)) + .ToList(); + + return matches.Count switch + { + 0 when throwWhenMissing => throw new InvalidOperationException( + $"Release version '{version}' on channel '{channel}' was not found for game '{gameId}'."), + 0 => -1, + > 1 => throw new InvalidOperationException( + $"Game '{gameId}' contains multiple releases with version '{version}' and channel '{channel}'. Refusing to guess which release to change."), + _ => matches[0].index + }; + } + + private static List GetOrCreateReleaseBucket(PluginRepoIndex index, string gameId) + { + var key = FindUniqueReleaseBucketKey(index.ReleasesByGameId, gameId); + if (key is not null) + return index.ReleasesByGameId[key]; + + var releases = new List(); + index.ReleasesByGameId[gameId] = releases; + return releases; + } + + private static void EnsureNoGameCollision( + PluginRepoIndex index, + string targetGameId, + int? excludedGameIndex, + string? excludedReleaseBucketKey, + string operation) + { + PathSafety.EnsureSafeId(targetGameId, "Game id"); + + foreach (var candidate in index.Games.Select((game, index) => new { game, index })) + { + if (excludedGameIndex.HasValue && candidate.index == excludedGameIndex.Value) + { + continue; + } + + if (IdentityComparer.Equals(candidate.game.GameId, targetGameId)) + { + throw new InvalidOperationException( + $"Can't {operation} game '{targetGameId}' because another game already uses that id (game ids are case-insensitive on Windows)." + ); + } + } + + foreach (var key in index.ReleasesByGameId.Keys) + { + if (string.Equals(key, excludedReleaseBucketKey, StringComparison.Ordinal)) + { + continue; + } + + if (IdentityComparer.Equals(key, targetGameId)) + { + throw new InvalidOperationException( + $"Can't {operation} game '{targetGameId}' because a release bucket already uses that id (game ids are case-insensitive on Windows)." + ); + } + } + } + + private static void ValidateGame(GameDefinition game) + { + PathSafety.EnsureSafeId(game.GameId, "Game id"); + EnsureRequiredText(game.DisplayName, "Game display name"); + EnsureDependencyIdsUnique(game.Dependencies, game.GameId); + + foreach (var dependency in game.Dependencies) + { + ValidateDependency(dependency); + } + } + + private static void EnsureDependencyIdsUnique(IEnumerable dependencies, string gameId) + { + var seen = new HashSet(IdentityComparer); + foreach (var dependency in dependencies) + { + PathSafety.EnsureSafeId(dependency.Id, "Dependency id"); + if (!seen.Add(dependency.Id)) + { + throw new InvalidOperationException( + $"Game '{gameId}' contains duplicate dependency id '{dependency.Id}'. Dependency ids are case-insensitive on Windows, so ids that differ only by capitalisation are ambiguous."); + } + } + } + + private static void ValidateDependency(Dependency dependency) + { + PathSafety.EnsureSafeId(dependency.Id, "Dependency id"); + EnsureRequiredText(dependency.Type, $"Dependency '{dependency.Id}' type"); + } + + private static void ValidateLifecycleScript(LifecycleScript script) + { + EnsureRequiredText(script.Executable, "Lifecycle script executable"); + EnsureRequiredText(script.What, "Lifecycle script what"); + EnsureRequiredText(script.Why, "Lifecycle script why"); + EnsureRequiredText(script.Modifies, "Lifecycle script modifies"); + } + + private static void ValidateRelease( + PluginRepoIndex index, + GameDefinition game, + ModRelease release) + { + EnsureRequiredText(release.Version, "Release version"); + EnsureRequiredText(release.Channel, "Release channel"); + EnsureRequiredText(release.Sha256, "Release SHA256"); + + if (!string.Equals(release.PluginId, index.PluginId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Release pluginId '{release.PluginId}' doesn't match project pluginId '{index.PluginId}'."); + } + + if (!string.Equals(release.GameId, game.GameId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Release gameId '{release.GameId}' doesn't match game '{game.GameId}'."); + } + + if (release.Sha256.Length != 64 || release.Sha256.Any(character => !Uri.IsHexDigit(character))) + throw new InvalidOperationException("Release SHA256 must contain exactly 64 hexadecimal characters."); + } + + private static void EnsureRequiredText(string? value, string description) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"{description} is required."); + } + } + + private static GameDefinition CopyGameWithLifecycle( + GameDefinition game, + LifecycleSlot slot, + LifecycleScript? script) => + slot switch + { + LifecycleSlot.PreInstall => new GameDefinition + { + GameId = game.GameId, + DisplayName = game.DisplayName, + ModName = game.ModName, + Description = game.Description, + SteamAppId = game.SteamAppId, + ExeName = game.ExeName, + ProbeRules = game.ProbeRules, + RegistryProbe = game.RegistryProbe, + AsciiPathShim = game.AsciiPathShim, + Dependencies = game.Dependencies, + Tags = game.Tags, + Languages = game.Languages, + DefaultPreInstall = script, + DefaultPostInstall = game.DefaultPostInstall, + DefaultPostUninstall = game.DefaultPostUninstall + }, + LifecycleSlot.PostInstall => new GameDefinition + { + GameId = game.GameId, + DisplayName = game.DisplayName, + ModName = game.ModName, + Description = game.Description, + SteamAppId = game.SteamAppId, + ExeName = game.ExeName, + ProbeRules = game.ProbeRules, + RegistryProbe = game.RegistryProbe, + AsciiPathShim = game.AsciiPathShim, + Dependencies = game.Dependencies, + Tags = game.Tags, + Languages = game.Languages, + DefaultPreInstall = game.DefaultPreInstall, + DefaultPostInstall = script, + DefaultPostUninstall = game.DefaultPostUninstall + }, + LifecycleSlot.PostUninstall => new GameDefinition + { + GameId = game.GameId, + DisplayName = game.DisplayName, + ModName = game.ModName, + Description = game.Description, + SteamAppId = game.SteamAppId, + ExeName = game.ExeName, + ProbeRules = game.ProbeRules, + RegistryProbe = game.RegistryProbe, + AsciiPathShim = game.AsciiPathShim, + Dependencies = game.Dependencies, + Tags = game.Tags, + Languages = game.Languages, + DefaultPreInstall = game.DefaultPreInstall, + DefaultPostInstall = game.DefaultPostInstall, + DefaultPostUninstall = script + }, + _ => throw new ArgumentOutOfRangeException(nameof(slot), slot, null) + }; + + private static ModRelease RewriteReleaseGameId(ModRelease release, string gameId) => + new() + { + GameId = gameId, + PluginId = release.PluginId, + Version = release.Version, + Channel = release.Channel, + PackageUrl = release.PackageUrl, + Sha256 = release.Sha256, + ChangelogUrl = release.ChangelogUrl, + Notes = release.Notes, + Compatibility = release.Compatibility, + Patreon = release.Patreon + }; + + private static T DeepClone(T value) + { + ArgumentNullException.ThrowIfNull(value); + + var json = JsonSerializer.Serialize(value, value.GetType(), JsonOptions); + var clone = JsonSerializer.Deserialize(json, JsonOptions); + return clone ?? throw new InvalidOperationException($"Couldn't clone {typeof(T).Name}."); + } + + private static T? DeepCloneOrNull(T? value) + where T : class => + value is null ? null : DeepClone(value); +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/CompleteReleasePublishWorkflow.cs b/src/AccessibilityModManager.Authoring/Workflows/CompleteReleasePublishWorkflow.cs new file mode 100644 index 0000000..6abf40d --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/CompleteReleasePublishWorkflow.cs @@ -0,0 +1,819 @@ +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; + +namespace AccessibilityModManager.Authoring.Workflows; + +public enum ReleaseAssetDestination +{ + GitHub, + Server, + PatreonPost +} + +public sealed record CompleteReleasePublishRequest( + ReleasePublishRequest Release, + PublishDestination IndexDestination, + string IndexCommitMessage, + bool DryRun, + ReleaseAssetDestination AssetDestination = ReleaseAssetDestination.GitHub, + string? PatreonAttachmentSelectionId = null); + +public sealed record CompleteReleasePublishPreview( + ReleasePublishPreview Release, + IndexPublishPreview Index, + PluginRepoIndex Candidate); + +public sealed record CompleteReleasePublishResult( + ModRelease Release, + string PublishedIndexSha256, + string IndexDestination, + IReadOnlyList CompletedPhases); + +public interface ICompleteReleasePublishWorkflow +{ + Task> PreviewAsync( + CompleteReleasePublishRequest request, + CancellationToken ct); + + Task> PublishAsync( + CompleteReleasePublishRequest request, + bool confirmed, + CancellationToken ct); +} + +/// +/// Composes release publication without hiding partial completion. Package publication precedes +/// the catalog. Restrictive Patreon gates precede new server bytes, while a changed or removed +/// gate follows an exact live-catalog read-back. Every reported phase is therefore a completed +/// fact rather than an optimistic plan. +/// +public sealed class CompleteReleasePublishWorkflow( + AuthorProjectContext projects, + CatalogWorkflow catalog, + IReleaseWorkflow releases, + IIndexWorkflow indexes, + IServerWorkflow? server = null, + IPatreonWorkflow? patreon = null, + IPublishedAssetProbe? publishedAssets = null) : ICompleteReleasePublishWorkflow +{ + private enum DeferredGateChange + { + None, + Set, + Remove + } + + private sealed record AssetPreview( + ReleasePublishPreview Preview, + ModRelease Release); + + private sealed record AssetPublication( + ModRelease Release, + string AssetPhase, + DeferredGateChange DeferredGate, + string? PublicUrl, + bool VerifyPublicBeforeCatalog); + + public async Task> PreviewAsync( + CompleteReleasePublishRequest request, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + var normalized = Normalize(request); + var reconciled = await indexes.ReconcileAsync( + normalized.Release.ProjectPath, + dryRun: true, + ct); + if (reconciled.ErrorKind != WorkflowErrorKind.None || reconciled.Value is null) + return ForwardFailure(reconciled); + + var asset = await PreviewAssetAsync(normalized, ct); + if (asset.ErrorKind != WorkflowErrorKind.None || asset.Value is null) + return ForwardFailure(asset); + + var candidate = StampGeneratedAt(catalog.AddRelease( + reconciled.Value, + normalized.Release.GameId, + asset.Value.Release)); + + var validation = indexes.Validate(candidate); + if (validation.PublishBlockers.Count > 0) + { + return new WorkflowResult( + "indexValidationFailed", + null, + new[] { "The complete catalog would not be publishable." } + .Concat(validation.PublishBlockers) + .ToArray(), + WorkflowErrorKind.Validation); + } + + var indexPreview = await indexes.PreviewPublishAsync( + new IndexPublishRequest( + normalized.Release.ProjectPath, + candidate, + normalized.IndexDestination, + normalized.IndexCommitMessage, + DryRun: true), + ct); + if (indexPreview.ErrorKind != WorkflowErrorKind.None || indexPreview.Value is null) + return ForwardFailure(indexPreview); + + var preview = new CompleteReleasePublishPreview( + asset.Value.Preview, + indexPreview.Value, + candidate); + return new WorkflowResult( + "completeReleasePreviewed", + preview, + new[] + { + $"The package destination is {asset.Value.Preview.DestinationDescription}.", + $"The updated catalog would publish to {indexPreview.Value.DestinationDescription}." + }); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + return Failure( + WorkflowErrorKind.Validation, + "completeReleasePreviewFailed", + ex.Message, + []); + } + } + + public async Task> PublishAsync( + CompleteReleasePublishRequest request, + bool confirmed, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + var phases = new List(); + + if (request.DryRun) + { + var preview = await PreviewAsync(request, ct); + if (preview.ErrorKind != WorkflowErrorKind.None || preview.Value is null) + return ForwardFailure(preview); + + var release = FindRelease(preview.Value.Candidate, request.Release.GameId, request.Release.Version, request.Release.Channel); + return new WorkflowResult( + "completeReleaseDryRun", + new CompleteReleasePublishResult( + release, + string.Empty, + preview.Value.Index.DestinationDescription, + []), + new[] { "Dry run completed; no lock, file write, commit, upload, gate change, or configuration change occurred." }); + } + + if (!confirmed) + { + return Failure( + WorkflowErrorKind.Conflict, + "confirmationRequired", + "Complete release publication requires confirmation after reviewing both the package and catalog destinations.", + phases); + } + + try + { + var normalized = Normalize(request); + await using var lease = await projects.AcquireWriteLeaseAsync(normalized.Release.ProjectPath, ct); + phases.Add("projectLocked"); + + var reconciled = await indexes.ReconcileAsync(normalized.Release.ProjectPath, dryRun: false, ct); + if (reconciled.ErrorKind != WorkflowErrorKind.None || reconciled.Value is null) + return ForwardFailure(reconciled, phases); + phases.Add("catalogReconciled"); + + var preparedResult = await PreparePackageAsync(normalized, ct); + if (preparedResult.ErrorKind != WorkflowErrorKind.None || preparedResult.Value is null) + return ForwardFailure(preparedResult, phases); + + await using var prepared = preparedResult.Value; + phases.Add("packageValidated"); + + var publishedAsset = await PublishAssetAsync(normalized, prepared, ct); + if (publishedAsset.ErrorKind != WorkflowErrorKind.None || publishedAsset.Value is null) + { + AddRemoteAssetPhaseIfCompleted(phases, publishedAsset.CompletedPhases); + return ForwardFailure(publishedAsset, phases); + } + + var asset = publishedAsset.Value; + phases.Add(asset.AssetPhase); + + if (asset.VerifyPublicBeforeCatalog) + { + var verified = await VerifyPublicAssetAsync(asset.PublicUrl, prepared.Sha256, ct); + if (verified.ErrorKind != WorkflowErrorKind.None) + return ForwardFailure(verified, phases); + phases.Add("publicAssetVerified"); + } + + var candidate = StampGeneratedAt(catalog.AddRelease( + reconciled.Value, + normalized.Release.GameId, + asset.Release)); + phases.Add("releaseRecorded"); + + var validation = indexes.Validate(candidate); + if (validation.PublishBlockers.Count > 0) + { + return new WorkflowResult( + "indexValidationFailed", + null, + new[] { "The package destination is ready, but the updated catalog is invalid." } + .Concat(validation.PublishBlockers) + .ToArray(), + WorkflowErrorKind.Validation, + phases.ToArray()); + } + phases.Add("indexValidated"); + + var saved = await indexes.SaveAsync( + normalized.Release.ProjectPath, + candidate, + dryRun: false, + ct); + if (saved.ErrorKind != WorkflowErrorKind.None) + return ForwardFailure(saved, phases); + phases.Add("indexSaved"); + + var publishedIndex = await indexes.PublishAsync( + new IndexPublishRequest( + normalized.Release.ProjectPath, + candidate, + normalized.IndexDestination, + normalized.IndexCommitMessage, + DryRun: false), + confirmed: true, + ct); + + if (publishedIndex.ErrorKind != WorkflowErrorKind.None || publishedIndex.Value is null) + { + AddIndexPhases(phases, publishedIndex.CompletedPhases); + return ForwardFailure(publishedIndex, phases); + } + + AddIndexPhases(phases, publishedIndex.Value.CompletedPhases); + + if (asset.DeferredGate == DeferredGateChange.Set) + { + var changed = await RequireServer().SetGateAsync( + asset.Release.GameId, + asset.Release.Version, + asset.Release.Patreon!, + confirmed: true, + dryRun: false, + ct); + if (changed.ErrorKind != WorkflowErrorKind.None) + return ForwardFailure(changed, phases); + phases.Add("gateUpdated"); + } + else if (asset.DeferredGate == DeferredGateChange.Remove) + { + var removed = await RequireServer().RemoveGateAsync( + asset.Release.GameId, + asset.Release.Version, + confirmed: true, + dryRun: false, + ct); + if (removed.ErrorKind != WorkflowErrorKind.None) + return ForwardFailure(removed, phases); + phases.Add("gateRemoved"); + + var verified = await VerifyPublicAssetAsync(asset.PublicUrl, prepared.Sha256, ct); + if (verified.ErrorKind != WorkflowErrorKind.None) + return ForwardFailure(verified, phases); + phases.Add("publicAssetVerified"); + } + + EnsureExactOrder(phases, normalized.AssetDestination, asset); + + var result = new CompleteReleasePublishResult( + asset.Release, + publishedIndex.Value.PublishedSha256, + publishedIndex.Value.DestinationDescription, + phases.ToArray()); + return new WorkflowResult( + "completeReleasePublished", + result, + new[] + { + $"Published release {asset.Release.Version} ({asset.Release.Channel}) and verified the live catalog." + }, + completedPhases: result.CompletedPhases); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure( + ex is IOException or UnauthorizedAccessException + ? WorkflowErrorKind.Conflict + : WorkflowErrorKind.Validation, + "completeReleasePublishFailed", + ex.Message, + phases); + } + } + + private async Task> PreviewAssetAsync( + CompleteReleasePublishRequest request, + CancellationToken ct) + { + if (request.AssetDestination == ReleaseAssetDestination.GitHub) + { + var preview = await releases.PreviewAsync(request.Release, ct); + if (preview.ErrorKind != WorkflowErrorKind.None || preview.Value is null) + return ForwardFailure(preview); + return Success( + "releaseDestinationPreviewed", + new AssetPreview(preview.Value, BuildGitHubRelease(request.Release, preview.Value)), + preview.Messages); + } + + var stagedResult = await releases.StagePackageAsync(ToPackageRequest(request.Release), ct); + if (stagedResult.ErrorKind != WorkflowErrorKind.None || stagedResult.Value is null) + return ForwardFailure(stagedResult); + + await using var staged = stagedResult.Value; + if (request.AssetDestination == ReleaseAssetDestination.Server) + { + var serverRequest = ToServerRequest(request.Release, staged.Preview.AssetFileName); + var inspected = await RequireServer().InspectPreparedReleaseAsync( + request.Release.PluginId, + serverRequest, + staged, + ct); + if (inspected.ErrorKind != WorkflowErrorKind.None || inspected.Value is null) + return ForwardFailure(inspected); + if (ValidateRemote(inspected.Value.Remote) is { } remoteError) + return Failure(WorkflowErrorKind.Conflict, remoteError.Status, remoteError.Message, []); + + var release = BuildServerRelease(request.Release, inspected.Value.PublicUrl, staged.Sha256); + var preview = new ReleasePublishPreview( + "author-server", + request.Release.Version, + staged.Preview.AssetFileName, + staged.Sha256, + CreatesRelease: !inspected.Value.Remote.PackageExists, + ReplacesAsset: false) + { + Destination = ReleaseAssetDestination.Server, + DestinationDescription = inspected.Value.PublicUrl + }; + return Success("releaseDestinationPreviewed", new AssetPreview(preview, release), inspected.Messages); + } + + var attachment = await ValidatePatreonAttachmentAsync(request, ct); + if (attachment.ErrorKind != WorkflowErrorKind.None || attachment.Value is null) + return ForwardFailure(attachment); + var patreonRelease = BuildPatreonRelease(request.Release, staged.Sha256, attachment.Value); + var patreonPreview = new ReleasePublishPreview( + "patreon", + request.Release.Patreon!.PostId!, + attachment.Value.FileName, + staged.Sha256, + CreatesRelease: false, + ReplacesAsset: false) + { + Destination = ReleaseAssetDestination.PatreonPost, + DestinationDescription = $"Patreon post {request.Release.Patreon.PostId}, attachment {attachment.Value.FileName}" + }; + return Success("releaseDestinationPreviewed", new AssetPreview(patreonPreview, patreonRelease), attachment.Messages); + } + + private Task> PreparePackageAsync( + CompleteReleasePublishRequest request, + CancellationToken ct) => + request.AssetDestination == ReleaseAssetDestination.GitHub + ? releases.PrepareAsync(request.Release, ct) + : releases.StagePackageAsync(ToPackageRequest(request.Release), ct); + + private async Task> PublishAssetAsync( + CompleteReleasePublishRequest request, + PreparedRelease prepared, + CancellationToken ct) + { + if (request.AssetDestination == ReleaseAssetDestination.GitHub) + { + var uploaded = await releases.PublishAsync(prepared, request.Release, confirmed: true, ct); + if (uploaded.ErrorKind != WorkflowErrorKind.None || uploaded.Value is null) + return ForwardFailure(uploaded); + return new WorkflowResult( + "assetPublished", + new AssetPublication( + uploaded.Value.Release, + "assetUploaded", + DeferredGateChange.None, + uploaded.Value.AssetUrl, + VerifyPublicBeforeCatalog: false), + uploaded.Messages, + completedPhases: uploaded.CompletedPhases); + } + + if (request.AssetDestination == ReleaseAssetDestination.Server) + { + var serverRequest = ToServerRequest(request.Release, prepared.Preview.AssetFileName); + var uploaded = await RequireServer().UploadPreparedReleaseAsync( + request.Release.PluginId, + serverRequest, + prepared, + confirmed: true, + dryRun: false, + ct); + if (uploaded.ErrorKind != WorkflowErrorKind.None || uploaded.Value is null) + return ForwardFailure(uploaded); + + var release = BuildServerRelease(request.Release, uploaded.Value.Outcome.PublicUrl, prepared.Sha256); + var deferred = uploaded.Value.Outcome.GateRemovalPending + ? DeferredGateChange.Remove + : uploaded.Value.Outcome.GateChangePending + ? DeferredGateChange.Set + : DeferredGateChange.None; + return new WorkflowResult( + "assetPublished", + new AssetPublication( + release, + "assetUploaded", + deferred, + uploaded.Value.Outcome.PublicUrl, + VerifyPublicBeforeCatalog: release.Patreon is null && deferred == DeferredGateChange.None), + uploaded.Messages); + } + + var attachment = await ValidatePatreonAttachmentAsync(request, ct); + if (attachment.ErrorKind != WorkflowErrorKind.None || attachment.Value is null) + return ForwardFailure(attachment); + return new WorkflowResult( + "patreonAssetValidated", + new AssetPublication( + BuildPatreonRelease(request.Release, prepared.Sha256, attachment.Value), + "assetValidated", + DeferredGateChange.None, + PublicUrl: null, + VerifyPublicBeforeCatalog: false), + new[] { $"Validated Patreon attachment {attachment.Value.FileName} for this release." }); + } + + private async Task> ValidatePatreonAttachmentAsync( + CompleteReleasePublishRequest request, + CancellationToken ct) + { + var gate = request.Release.Patreon; + if (gate is null) + return Failure(WorkflowErrorKind.Validation, "patreonGateMissing", "A Patreon-post release requires Patreon gate metadata.", []); + if (ValidateGate(gate, requirePost: true) is { } gateError) + return Failure(WorkflowErrorKind.Validation, "patreonGateInvalid", gateError, []); + if (string.IsNullOrWhiteSpace(request.PatreonAttachmentSelectionId)) + { + return Failure( + WorkflowErrorKind.Validation, + "patreonAttachmentSelectionRequired", + "Validate the Patreon post and supply the stable attachment selection id.", + []); + } + + var inspected = await RequirePatreon().InspectPostAsync( + $"https://www.patreon.com/posts/{gate.PostId}", + ct); + if (inspected.ErrorKind != WorkflowErrorKind.None || inspected.Value is null) + return ForwardFailure(inspected); + if (!string.Equals(inspected.Value.PostId, gate.PostId, StringComparison.Ordinal)) + { + return Failure( + WorkflowErrorKind.Conflict, + "patreonPostChanged", + "Patreon returned a different post identity than the release requested.", + []); + } + + var matches = inspected.Value.Attachments + .Where(candidate => string.Equals( + candidate.SelectionId, + request.PatreonAttachmentSelectionId, + StringComparison.Ordinal)) + .ToArray(); + if (matches.Length != 1) + { + return Failure( + WorkflowErrorKind.Validation, + "patreonAttachmentNotFound", + "The selected attachment id is not present exactly once on that Patreon post. Validate the post again and use a current selection id.", + []); + } + + return Success("patreonAttachmentSelected", matches[0], inspected.Messages); + } + + private async Task> VerifyPublicAssetAsync( + string? publicUrl, + string expectedSha256, + CancellationToken ct) + { + if (publishedAssets is null) + return Failure(WorkflowErrorKind.Conflict, "publishedAssetProbeUnavailable", "Public-asset verification is unavailable.", []); + if (!Uri.TryCreate(publicUrl, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return Failure(WorkflowErrorKind.Validation, "publicAssetUrlInvalid", "The server returned no valid HTTPS public package URL.", []); + } + + var state = await publishedAssets.ProbeAsync(uri, ct); + if (state.Status != PublishedAssetStatus.Found) + { + return Failure( + WorkflowErrorKind.Conflict, + "publicAssetUnreachable", + $"The package was published, but {uri} could not be read through the public web address.", + []); + } + if (!string.Equals(state.Sha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + return Failure( + WorkflowErrorKind.Conflict, + "publicAssetMismatch", + $"The public web address {uri} serves different bytes than the validated package.", + []); + } + + return Success("publicAssetVerified", true, new[] { "The public web address serves the exact validated package bytes." }); + } + + private static CompleteReleasePublishRequest Normalize(CompleteReleasePublishRequest request) + { + ArgumentNullException.ThrowIfNull(request.Release); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Release.ProjectPath); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Release.PluginId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Release.GameId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Release.Version); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Release.Channel); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Release.LocalZipPath); + if (request.IndexDestination == PublishDestination.Unset) + throw new InvalidOperationException("No catalog publishing destination is selected."); + if (request.Release.ChangelogUrl is { Length: > 0 } changelog && + (!Uri.TryCreate(changelog, UriKind.Absolute, out var changelogUri) || + !string.Equals(changelogUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException("Changelog URL must be an absolute https:// URL."); + } + if (request.AssetDestination == ReleaseAssetDestination.GitHub && request.Release.Patreon is not null) + throw new InvalidOperationException("Patreon-gated bytes cannot be published on a public GitHub release."); + if (request.AssetDestination == ReleaseAssetDestination.PatreonPost && request.Release.Patreon is null) + throw new InvalidOperationException("A Patreon-post destination requires Patreon gate metadata."); + + return request with + { + Release = request.Release with + { + ProjectPath = Path.GetFullPath(request.Release.ProjectPath), + PluginId = request.Release.PluginId.Trim(), + GameId = request.Release.GameId.Trim(), + Version = request.Release.Version.Trim(), + Channel = request.Release.Channel.Trim(), + SourceRepo = request.Release.SourceRepo?.Trim() ?? string.Empty, + LocalZipPath = Path.GetFullPath(request.Release.LocalZipPath), + AssetFileName = NullIfBlank(request.Release.AssetFileName), + Notes = NullIfBlank(request.Release.Notes), + ChangelogUrl = NullIfBlank(request.Release.ChangelogUrl) + }, + IndexCommitMessage = string.IsNullOrWhiteSpace(request.IndexCommitMessage) + ? "Update accessibility mod index" + : request.IndexCommitMessage.Trim(), + PatreonAttachmentSelectionId = NullIfBlank(request.PatreonAttachmentSelectionId) + }; + } + + private static PackageStageRequest ToPackageRequest(ReleasePublishRequest request) => + new(request.PluginId, request.GameId, request.Version, request.LocalZipPath, request.AssetFileName); + + private static ServerReleaseRequest ToServerRequest(ReleasePublishRequest request, string assetFileName) => + new(request.GameId, request.Version, assetFileName, request.LocalZipPath, request.Patreon); + + private static ModRelease BuildGitHubRelease( + ReleasePublishRequest request, + ReleasePublishPreview preview) => + BuildBaseRelease( + request, + preview.Sha256, + GitHubService.BuildAssetUrl(preview.Repository, preview.Tag, preview.AssetFileName), + patreonGate: null); + + private static ModRelease BuildServerRelease( + ReleasePublishRequest request, + string publicUrl, + string sha256) + { + if (!Uri.TryCreate(publicUrl, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("The server produced no valid HTTPS public URL for the release."); + } + + if (request.Patreon is null) + return BuildBaseRelease(request, sha256, uri, patreonGate: null); + if (ValidateGate(request.Patreon, requirePost: false) is { } gateError) + throw new InvalidOperationException(gateError); + + var gate = new PatreonGate + { + CampaignId = request.Patreon.CampaignId.Trim(), + TierIds = request.Patreon.TierIds.Select(value => value.Trim()).ToList(), + PostId = null, + AttachmentFileName = null, + ServerUrl = uri.AbsoluteUri + }; + return BuildBaseRelease(request, sha256, packageUrl: null, gate); + } + + private static ModRelease BuildPatreonRelease( + ReleasePublishRequest request, + string sha256, + PatreonAttachmentInfo attachment) + { + var input = request.Patreon!; + var gate = new PatreonGate + { + CampaignId = input.CampaignId.Trim(), + TierIds = input.TierIds.Select(value => value.Trim()).ToList(), + PostId = input.PostId, + AttachmentFileName = attachment.FileName, + ServerUrl = null + }; + return BuildBaseRelease(request, sha256, packageUrl: null, gate); + } + + private static ModRelease BuildBaseRelease( + ReleasePublishRequest request, + string sha256, + Uri? packageUrl, + PatreonGate? patreonGate) => + new() + { + GameId = request.GameId, + PluginId = request.PluginId, + Version = request.Version, + Channel = request.Channel, + PackageUrl = packageUrl, + Sha256 = sha256, + ChangelogUrl = NullIfBlank(request.ChangelogUrl), + Notes = NullIfBlank(request.Notes), + Patreon = patreonGate + }; + + private static (string Status, string Message)? ValidateRemote( + ServerUploadService.RemoteReleaseState remote) + { + if (remote.OtherAssets.Count > 0) + { + return ( + "serverVersionFolderOccupied", + $"The server version folder already contains another package: {string.Join(", ", remote.OtherAssets)}."); + } + if (remote.PackageExists && !remote.PackageMatches) + { + return ( + "serverReleaseImmutable", + "This server version already exists with different bytes. Bump the version instead of replacing it."); + } + return null; + } + + private static string? ValidateGate(PatreonGate gate, bool requirePost) + { + if (string.IsNullOrWhiteSpace(gate.CampaignId)) + return "Patreon campaign id is required."; + if (gate.TierIds.Count == 0 || gate.TierIds.Any(string.IsNullOrWhiteSpace)) + return "At least one nonempty Patreon tier id is required."; + if (gate.TierIds.Distinct(StringComparer.Ordinal).Count() != gate.TierIds.Count) + return "Patreon tier ids must be unique."; + if (requirePost && (string.IsNullOrWhiteSpace(gate.PostId) || !gate.PostId.All(char.IsAsciiDigit))) + return "A numeric Patreon post id is required for Patreon-post delivery."; + return null; + } + + private static PluginRepoIndex StampGeneratedAt(PluginRepoIndex candidate) => + new() + { + PluginId = candidate.PluginId, + RepoVersion = candidate.RepoVersion, + GeneratedAt = DateTime.UtcNow, + Games = candidate.Games, + ReleasesByGameId = candidate.ReleasesByGameId, + Author = candidate.Author, + DependencyPresets = candidate.DependencyPresets + }; + + private static ModRelease FindRelease( + PluginRepoIndex candidate, + string gameId, + string version, + string channel) => + candidate.ReleasesByGameId[gameId].Single(release => + string.Equals(release.Version, version, StringComparison.Ordinal) && + string.Equals(release.Channel, channel, StringComparison.Ordinal)); + + private IServerWorkflow RequireServer() => + server ?? throw new InvalidOperationException("Server authoring is unavailable in this build."); + + private IPatreonWorkflow RequirePatreon() => + patreon ?? throw new InvalidOperationException("Patreon authoring is unavailable in this build."); + + private static string? NullIfBlank(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static void AddRemoteAssetPhaseIfCompleted( + ICollection phases, + IReadOnlyList? releasePhases) + { + if (releasePhases?.Any(phase => phase is + "githubReleaseCreated" or + "githubAssetUploaded" or + "githubAssetAlreadyMatched") == true) + { + phases.Add("assetUploaded"); + } + } + + private static void AddIndexPhases( + ICollection phases, + IReadOnlyList? indexPhases) + { + if (indexPhases?.Contains("indexPublished", StringComparer.Ordinal) == true) + phases.Add("indexPublished"); + if (indexPhases?.Contains("liveVerified", StringComparer.Ordinal) == true) + phases.Add("liveVerified"); + } + + private static void EnsureExactOrder( + IReadOnlyList phases, + ReleaseAssetDestination destination, + AssetPublication asset) + { + var expected = new List + { + "projectLocked", + "catalogReconciled", + "packageValidated", + destination == ReleaseAssetDestination.PatreonPost ? "assetValidated" : "assetUploaded" + }; + if (asset.VerifyPublicBeforeCatalog) + expected.Add("publicAssetVerified"); + expected.AddRange( + [ + "releaseRecorded", + "indexValidated", + "indexSaved", + "indexPublished", + "liveVerified" + ]); + if (asset.DeferredGate == DeferredGateChange.Set) + expected.Add("gateUpdated"); + else if (asset.DeferredGate == DeferredGateChange.Remove) + { + expected.Add("gateRemoved"); + expected.Add("publicAssetVerified"); + } + + if (!phases.SequenceEqual(expected, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + "The complete release transaction reported phases out of order; refusing to call it complete."); + } + } + + private static WorkflowResult Success( + string status, + T value, + IReadOnlyList messages) => + new(status, value, messages); + + private static WorkflowResult ForwardFailure( + WorkflowResult result, + IReadOnlyList? completedPhases = null) => + new( + result.Status, + default, + result.Messages, + result.ErrorKind == WorkflowErrorKind.None ? WorkflowErrorKind.Conflict : result.ErrorKind, + completedPhases ?? result.CompletedPhases); + + private static WorkflowResult Failure( + WorkflowErrorKind kind, + string status, + string message, + IReadOnlyList phases) => + new(status, default, new[] { message }, kind, phases.ToArray()); +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/DependencyPresetCatalog.cs b/src/AccessibilityModManager.Authoring/Workflows/DependencyPresetCatalog.cs new file mode 100644 index 0000000..a2f4aba --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/DependencyPresetCatalog.cs @@ -0,0 +1,243 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using AccessibilityModManager.Core.Models; + +namespace AccessibilityModManager.Authoring.Workflows; + +public sealed class DependencyPresetDefinition +{ + public required string Id { get; init; } + public required string DisplayName { get; init; } + public required string Description { get; init; } + public required Func Build { get; init; } + + public Dependency ToDependency() => Build(); + public override string ToString() => DisplayName; +} + +public static class DependencyPresetCatalog +{ + public static IReadOnlyList All { get; } = Array.AsReadOnly( + [ + new DependencyPresetDefinition + { + Id = "emulator", + DisplayName = "Emulator (portable app)", + Description = "The emulator itself, delivered as a portable ZIP. \"This dependency is the " + + "game itself\" is already ticked and the auto-install kind is set to extractApp. " + + "Set the game's Exe name (General tab) to the emulator's exe, then paste the " + + "ZIP's HTTPS URL below and click \"Fetch from URL\" for the SHA256.", + Build = BuildPortableEmulator + }, + new DependencyPresetDefinition + { + Id = "melonloader", + DisplayName = "MelonLoader", + Description = "MelonLoader runtime; checked by version.dll in the game folder.", + Build = BuildMelonLoader + }, + new DependencyPresetDefinition + { + Id = "bepinex", + DisplayName = "BepInEx", + Description = "BepInEx framework; checked by winhttp.dll in the game folder.", + Build = BuildBepInEx + }, + new DependencyPresetDefinition + { + Id = "dotnet-10-desktop", + DisplayName = ".NET 10 Desktop Runtime", + Description = "Required for managers/mods that need the .NET 10 runtime. Checked via " + + "the runtime's registry record (version-named entries; the x64 runtime " + + "records under the 32-bit registry view, which the checker probes automatically).", + Build = BuildNet10Desktop + }, + Net9Desktop( + id: "dotnet-9-desktop-x64", + bits: 64, + registryArch: "x64", + installerUrl: Net9X64Url, + sha256: Net9X64Sha256), + Net9Desktop( + id: "dotnet-9-desktop-x86", + bits: 32, + registryArch: "x86", + installerUrl: Net9X86Url, + sha256: Net9X86Sha256) + ]); + + private static readonly IReadOnlyDictionary PresetsById = + All.ToDictionary(preset => preset.Id, StringComparer.OrdinalIgnoreCase); + + // .NET 9.0.18, the current 9.0 patch as of 2026-08-04. Both URLs came from Microsoft's own + // release metadata (release-metadata/9.0/releases.json) rather than being typed out, and each + // file was downloaded and its SHA512 checked against the hash that metadata publishes — so the + // SHA256 below is provably the hash of the genuine installer, not merely of whatever answered. + // + // Pinned to an exact patch on purpose: the manager's SHA256 gate is absolute, so an "always + // latest" address would start failing the moment Microsoft ships 9.0.19. Bumping this preset is + // a deliberate edit, and the hash has to be re-derived with it. + private const string Net9X64Url = + "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.18/windowsdesktop-runtime-9.0.18-win-x64.exe"; + private const string Net9X64Sha256 = + "12cd00688fc9f8f5187d25911bf656db61998c264f03eef4022ff2d9321d6982"; + + private const string Net9X86Url = + "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/9.0.18/windowsdesktop-runtime-9.0.18-win-x86.exe"; + private const string Net9X86Sha256 = + "a90bc401a7838f036a4d615ca7031099b4b950ed6a8f59f59c44150c6ad7d648"; + + public static Dependency CreateDependency(string presetId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(presetId); + + if (!TryCreateDependency(presetId, out var dependency)) + { + throw new InvalidOperationException($"Dependency preset '{presetId}' was not found."); + } + + return dependency; + } + + public static bool TryCreateDependency(string presetId, [NotNullWhen(true)] out Dependency? dependency) + { + if (string.IsNullOrWhiteSpace(presetId) || !PresetsById.TryGetValue(presetId, out var preset)) + { + dependency = null; + return false; + } + + dependency = preset.ToDependency(); + return true; + } + + public static bool TryGet(string presetId, [NotNullWhen(true)] out DependencyPresetDefinition? preset) + { + if (string.IsNullOrWhiteSpace(presetId)) + { + preset = null; + return false; + } + + return PresetsById.TryGetValue(presetId, out preset); + } + + private static Dependency BuildPortableEmulator() => + new() + { + Id = "emulator", + Type = "system", + Required = true, + IsGameInstaller = true, + Fix = new DependencyFix + { + // Author fills these in: the emulator ZIP's HTTPS URL, and its SHA256 (Fetch from URL). + DownloadUrl = "", + AutoInstall = new ExtractAppAutoInstall { Sha256 = "" } + } + }; + + private static Dependency BuildMelonLoader() => + new() + { + Id = "melonloader", + Type = "framework", + Required = true, + Check = new DependencyCheck { FilePath = "version.dll" }, + Fix = new DependencyFix { DownloadUrl = "https://github.com/LavaGang/MelonLoader/releases" } + }; + + private static Dependency BuildBepInEx() => + new() + { + Id = "bepinex", + Type = "framework", + Required = true, + Check = new DependencyCheck { FilePath = "winhttp.dll" }, + Fix = new DependencyFix { DownloadUrl = "https://github.com/BepInEx/BepInEx/releases" } + }; + + private static Dependency BuildNet10Desktop() => + new() + { + Id = "dotnet-10-desktop", + Type = "system", + Required = true, + MinVersion = "10.0.0", + Check = new DependencyCheck + { + // Deliberately NO RegistryValue and NO view pin: the installed versions are + // the value NAMES under this key (highest wins vs MinVersion), and the x64 + // runtime writes it under the 32-bit view — the default both-views probe is + // what finds it (audit finding 10; verified against a real install 2026-07-25). + RegistryKey = @"SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App" + }, + Fix = new DependencyFix { DownloadUrl = "https://dotnet.microsoft.com/download/dotnet/10.0" } + }; + + /// + /// The .NET 9 Desktop Runtime, in one architecture, ready to install without the author filling + /// anything in. + /// + /// Which one a game needs is not a detail. A 32-bit game loads the 32-bit runtime + /// and a 64-bit game the 64-bit one; installing the wrong one leaves the mod unable to start + /// with nothing obviously wrong. They install side by side, so a machine can want both — which + /// is why these are two presets with two ids rather than one with a switch. + /// + /// How the check works. Installed versions are the value NAMES under the key, and + /// the checker takes the highest and compares it against MinVersion. The architecture is part of + /// the KEY PATH (…\x64\… vs …\x86\…), not the registry view — both actually live under + /// WOW6432Node, and the checker probes both views by default, which is what finds them. Verified + /// against a real machine on 2026-08-04, where x64 held 6.0.5 through 10.0.8 and x86 held 5.0.17 + /// through 10.0.10. + /// + /// The one thing to know: "highest wins" means a machine with only .NET 10 passes a + /// MinVersion of 9.0.0, and a mod built for net9.0 will NOT run on 10 alone — .NET rolls forward + /// across patches, not across major versions. Getting that exactly right needs the check to be + /// able to say "some 9.x", which it currently cannot express. + /// + private static DependencyPresetDefinition Net9Desktop( + string id, + int bits, + string registryArch, + string installerUrl, + string sha256) => + new() + { + Id = id, + DisplayName = $".NET 9 Desktop Runtime ({bits}-bit)", + Description = + $"The {bits}-bit .NET 9 Desktop Runtime (9.0.18), for a {bits}-bit game. The download " + + "address and SHA256 are already filled in and verified, and the manager installs it " + + "silently with the user's consent. Checked by the runtime's own registry record, so " + + "any 9.x or newer counts — no exact patch to keep up to date. Pick the architecture " + + "that matches the game: the wrong one leaves the mod unable to start.", + Build = () => new Dependency + { + Id = $"dotnet-9-desktop-{registryArch}", + Type = "system", + Required = true, + MinVersion = "9.0.0", + Check = new DependencyCheck + { + // No RegistryValue and no view pin, matching the .NET 10 preset: the versions are + // the value names, and the record lives under the 32-bit view that the default + // both-views probe reaches. + RegistryKey = + $@"SOFTWARE\dotnet\Setup\InstalledVersions\{registryArch}\sharedfx\Microsoft.WindowsDesktop.App" + }, + Fix = new DependencyFix + { + DownloadUrl = installerUrl, + AutoInstall = new RunInstallerAutoInstall + { + Sha256 = sha256, + // Microsoft's own switches. /norestart matters: the installer will otherwise + // reboot the machine out from under someone mid-install. + Args = ["/install", "/quiet", "/norestart"], + NeedsAdmin = true + } + } + } + }; +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/IndexWorkflow.cs b/src/AccessibilityModManager.Authoring/Workflows/IndexWorkflow.cs new file mode 100644 index 0000000..3b25044 --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/IndexWorkflow.cs @@ -0,0 +1,738 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using AccessibilityModManager.Infrastructure.CatalogClaims; +using AccessibilityModManager.Infrastructure.Security; +using AccessibilityModManager.Infrastructure.Services; +using Serilog; + +namespace AccessibilityModManager.Authoring.Workflows; + +public sealed record IndexPublishRequest( + string ProjectPath, + PluginRepoIndex Candidate, + PublishDestination Destination, + string CommitMessage, + bool DryRun); + +public sealed record IndexPublishPreview( + string PluginId, + PublishDestination Destination, + string DestinationDescription, + string CommitMessage, + IReadOnlyList CatalogChanges); + +public sealed record IndexPublishResult( + string PluginId, + string PublishedSha256, + string DestinationDescription, + IReadOnlyList CompletedPhases); + +public interface IIndexWorkflow +{ + IndexValidationReport Validate(PluginRepoIndex candidate); + Task> ReconcileAsync(string projectPath, CancellationToken ct); + Task> ReconcileAsync( + string projectPath, + bool dryRun, + CancellationToken ct); + Task> ReconcileAsync( + string projectPath, + bool dryRun, + bool confirmAdoption, + CancellationToken ct) => + ReconcileAsync(projectPath, dryRun, ct); + Task> SaveAsync( + string projectPath, + PluginRepoIndex candidate, + bool dryRun, + CancellationToken ct); + Task> PreviewPublishAsync( + IndexPublishRequest request, + CancellationToken ct); + Task> PublishAsync( + IndexPublishRequest request, + bool confirmed, + CancellationToken ct); + Task> InspectLockAsync( + string pluginId, + CancellationToken ct); + Task> BreakLockAsync( + string pluginId, + string expectedFingerprint, + bool confirmed, + CancellationToken ct); +} + +public sealed class IndexWorkflow : IIndexWorkflow +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never + }; + + private readonly ProjectReconciler _reconciler; + private readonly IndexPublishCoordinator _coordinator; + private readonly GitHubIndexPublisher _gitHubPublisher; + private readonly UnsignedPublishGate _unsignedGate; + private readonly RegistryMembershipChecker _registryChecker; + private readonly ServerUploadService _server; + private readonly AuthorConfigService _config; + private readonly IndexFileService _indexFiles; + private readonly IGitHubService _gitHub; + private readonly HttpClient _http; + private readonly ILogger _logger; + + public IndexWorkflow( + ProjectReconciler reconciler, + IndexPublishCoordinator coordinator, + GitHubIndexPublisher gitHubPublisher, + UnsignedPublishGate unsignedGate, + RegistryMembershipChecker registryChecker, + ServerUploadService server, + AuthorConfigService config, + IndexFileService indexFiles, + IGitHubService gitHub, + HttpClient http, + ILogger logger) + { + _reconciler = reconciler ?? throw new ArgumentNullException(nameof(reconciler)); + _coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); + _gitHubPublisher = gitHubPublisher ?? throw new ArgumentNullException(nameof(gitHubPublisher)); + _unsignedGate = unsignedGate ?? throw new ArgumentNullException(nameof(unsignedGate)); + _registryChecker = registryChecker ?? throw new ArgumentNullException(nameof(registryChecker)); + _server = server ?? throw new ArgumentNullException(nameof(server)); + _config = config ?? throw new ArgumentNullException(nameof(config)); + _indexFiles = indexFiles ?? throw new ArgumentNullException(nameof(indexFiles)); + _gitHub = gitHub ?? throw new ArgumentNullException(nameof(gitHub)); + _http = http ?? throw new ArgumentNullException(nameof(http)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IndexValidationReport Validate(PluginRepoIndex candidate) + { + ArgumentNullException.ThrowIfNull(candidate); + return PluginIndexValidation.Validate(candidate.PluginId, SerializeText(candidate, trailingNewline: false)); + } + + public Task> ReconcileAsync( + string projectPath, + CancellationToken ct) => + ReconcileAsync(projectPath, dryRun: false, confirmAdoption: false, ct); + + public Task> ReconcileAsync( + string projectPath, + bool dryRun, + CancellationToken ct) => + ReconcileAsync(projectPath, dryRun, confirmAdoption: false, ct); + + public async Task> ReconcileAsync( + string projectPath, + bool dryRun, + bool confirmAdoption, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); + var fullProjectPath = Path.GetFullPath(projectPath); + var indexPath = IndexFileService.GetIndexPath(fullProjectPath); + if (!File.Exists(indexPath)) + return Failure(WorkflowErrorKind.Validation, "indexMissing", $"index.json not found at {indexPath}"); + + var localBytes = await File.ReadAllBytesAsync(indexPath, ct); + PluginRepoIndex local; + try + { + local = _indexFiles.Load(fullProjectPath); + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Validation, "indexLoadFailed", ex.Message); + } + + var serverConfig = _config.GetServerUploadConfig(); + var outcome = await _reconciler.InspectAsync( + serverConfig is null ? null : new ServerUploadPublishTransport(_server, serverConfig), + new RegistryVerifiedSource(_registryChecker), + local.PluginId, + localBytes, + _config.GetLastPublishedIndexSha(fullProjectPath), + ct); + + switch (outcome.Action) + { + case ReconcileAction.Nothing: + return Success("catalogAlreadyCurrent", local, "The local catalog is already current."); + case ReconcileAction.Explain: + return Failure( + WorkflowErrorKind.Conflict, + "catalogReconcileBlocked", + outcome.Message ?? "The published catalog could not be reconciled safely.", + local); + case ReconcileAction.Unsigned: + return await ReconcileUnsignedAsync( + fullProjectPath, + local, + localBytes, + dryRun, + confirmAdoption, + ct); + case ReconcileAction.AdoptWithConsent when !confirmAdoption: + { + var candidate = Deserialize(outcome.Document!); + return Failure( + WorkflowErrorKind.Conflict, + "catalogAdoptionConfirmationRequired", + outcome.Message ?? "Adopting the published catalog would replace unpublished local work.", + candidate); + } + case ReconcileAction.AdoptWithConsent: + case ReconcileAction.Adopt: + { + var replacement = outcome.Document!; + if (dryRun) + { + return Success( + "catalogReconcilePreviewed", + Deserialize(replacement), + $"Verified publish {outcome.Generation} would replace the stale local catalog."); + } + + var adoption = LocalIndexAdoption.ReplaceIfUnchanged(indexPath, localBytes, replacement, out var error); + if (adoption != AdoptionResult.Replaced) + { + return Failure( + WorkflowErrorKind.Conflict, + "catalogAdoptionSuperseded", + error ?? "index.json changed while the published catalog was being reconciled."); + } + + RecordPublishedBytes(fullProjectPath, replacement); + return Success( + "catalogReconciled", + Deserialize(replacement), + $"Adopted verified publish {outcome.Generation} from the server."); + } + default: + return Failure(WorkflowErrorKind.Conflict, "catalogReconcileBlocked", "Unknown reconciliation result."); + } + } + + public async Task> SaveAsync( + string projectPath, + PluginRepoIndex candidate, + bool dryRun, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); + ArgumentNullException.ThrowIfNull(candidate); + ct.ThrowIfCancellationRequested(); + + var report = Validate(candidate); + if (report.PublishBlockers.Count > 0) + { + return new WorkflowResult( + "indexValidationFailed", + null, + new[] { "The index cannot be saved for publication." }.Concat(report.PublishBlockers).ToArray(), + WorkflowErrorKind.Validation); + } + + var bytes = SerializeBytes(candidate); + var sha = Convert.ToHexStringLower(SHA256.HashData(bytes)); + if (!dryRun) + { + var indexPath = IndexFileService.GetIndexPath(Path.GetFullPath(projectPath)); + DurableFile.Write(indexPath, bytes); + } + + return Success( + dryRun ? "indexSavePreviewed" : "indexSaved", + sha, + dryRun ? $"index.json is valid and would be saved with SHA256 {sha}." : $"Saved index.json with SHA256 {sha}."); + } + + public async Task> PreviewPublishAsync( + IndexPublishRequest request, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + var validation = Validate(request.Candidate); + if (validation.PublishBlockers.Count > 0) + { + return new WorkflowResult( + "indexValidationFailed", + null, + new[] { "The index cannot be published." }.Concat(validation.PublishBlockers).ToArray(), + WorkflowErrorKind.Validation); + } + + if (request.Destination == PublishDestination.Unset) + { + return Failure( + WorkflowErrorKind.Validation, + "publishDestinationMissing", + "No index publishing destination is selected. Choose GitHub or server first."); + } + + var projectPath = Path.GetFullPath(request.ProjectPath); + var changes = DescribeChanges(projectPath, request.Candidate); + + if (request.Destination == PublishDestination.GitHub) + { + var (target, error) = await _gitHubPublisher.ResolveTargetAsync(projectPath, ct); + if (target is null) + return Failure(WorkflowErrorKind.Validation, "githubTargetInvalid", error ?? "Couldn't resolve the GitHub target."); + + var registry = new RegistryVerifiedSource(_registryChecker); + var authorized = await _unsignedGate.AuthorizeAsync(registry, request.Candidate.PluginId, ct); + if (!authorized.Allowed) + return Failure(WorkflowErrorKind.Authentication, "unsignedPublishRefused", authorized.Message); + + var privateState = await _gitHub.IsRepoPrivateAsync($"{target.Owner}/{target.Repo}", ct); + if (privateState is true) + return Failure(WorkflowErrorKind.Validation, "privateRepositoryRefused", $"{target.Describe} is private, so managers cannot read its raw index anonymously."); + if (privateState is null) + return Failure(WorkflowErrorKind.Conflict, "repositoryVisibilityUnknown", $"Couldn't verify whether {target.Describe} is public."); + + if (authorized.RegisteredIndexUrl is { } registered && + !string.Equals(registered.TrimEnd('/'), target.BranchRawUrl, StringComparison.Ordinal)) + { + return Failure( + WorkflowErrorKind.Conflict, + "registeredIndexUrlMismatch", + $"The registry tells managers to read '{registered}', but this project would publish '{target.BranchRawUrl}'."); + } + + return Success( + "indexPublishPreviewed", + new IndexPublishPreview( + request.Candidate.PluginId, + request.Destination, + target.Describe, + NormalizeCommitMessage(request.CommitMessage), + changes), + $"Index publication is valid and would push to {target.Describe}."); + } + + var cfg = _config.GetServerUploadConfig(); + if (cfg is null) + return Failure(WorkflowErrorKind.Validation, "serverNotConfigured", "Server upload is not configured."); + + return Success( + "indexPublishPreviewed", + new IndexPublishPreview( + request.Candidate.PluginId, + request.Destination, + $"{cfg.Host} at {IndexPublishCoordinator.CanonicalIndexUrl(request.Candidate.PluginId)}", + NormalizeCommitMessage(request.CommitMessage), + changes), + $"Index publication is valid and would upload atomically to {cfg.Host}."); + } + + public async Task> PublishAsync( + IndexPublishRequest request, + bool confirmed, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + var previewResult = await PreviewPublishAsync(request, ct); + if (previewResult.ErrorKind != WorkflowErrorKind.None || previewResult.Value is null) + { + return new WorkflowResult( + previewResult.Status, + null, + previewResult.Messages, + previewResult.ErrorKind, + previewResult.CompletedPhases); + } + + if (request.DryRun) + { + return Success( + "indexPublishDryRun", + new IndexPublishResult( + request.Candidate.PluginId, + Convert.ToHexStringLower(SHA256.HashData(SerializeBytes(request.Candidate))), + previewResult.Value.DestinationDescription, + Array.Empty()), + "Dry run completed; nothing was committed, uploaded, or changed."); + } + + if (!confirmed) + { + return Failure( + WorkflowErrorKind.Conflict, + "confirmationRequired", + $"Publishing requires confirmation of this exact destination: {previewResult.Value.DestinationDescription}."); + } + + return request.Destination == PublishDestination.GitHub + ? await PublishGitHubAsync(request, previewResult.Value, ct) + : await PublishServerAsync(request, previewResult.Value, ct); + } + + public async Task> InspectLockAsync( + string pluginId, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + var cfg = _config.GetServerUploadConfig(); + if (cfg is null) + return Failure(WorkflowErrorKind.Validation, "serverNotConfigured", "Server upload is not configured."); + + try + { + var remoteLock = await _server.ReadPublishLockAsync(cfg, pluginId, ct); + return Success( + "publishLockInspected", + remoteLock, + remoteLock.Present + ? $"A publish lock is present with fingerprint {remoteLock.Fingerprint}." + : "No publish lock is present."); + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Conflict, "publishLockReadFailed", ex.Message); + } + } + + public async Task> BreakLockAsync( + string pluginId, + string expectedFingerprint, + bool confirmed, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedFingerprint); + if (!confirmed) + return Failure(WorkflowErrorKind.Conflict, "confirmationRequired", "Breaking a publish lock requires confirmation after reading its fingerprint."); + + var cfg = _config.GetServerUploadConfig(); + if (cfg is null) + return Failure(WorkflowErrorKind.Validation, "serverNotConfigured", "Server upload is not configured."); + + try + { + var removed = await _server.BreakPublishLockAsync(cfg, pluginId, expectedFingerprint, ct); + return removed + ? Success("publishLockBroken", true, "Cleared the exact publish lock that was displayed.") + : Failure(WorkflowErrorKind.Conflict, "publishLockChanged", "The publish lock changed after it was displayed, so it was left alone."); + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Conflict, "publishLockBreakFailed", ex.Message); + } + } + + private async Task> PublishGitHubAsync( + IndexPublishRequest request, + IndexPublishPreview preview, + CancellationToken ct) + { + var (target, targetError) = await _gitHubPublisher.ResolveTargetAsync(request.ProjectPath, ct); + if (target is null) + return Failure(WorkflowErrorKind.Validation, "githubTargetInvalid", targetError ?? "Couldn't resolve the GitHub target."); + + var registry = new RegistryVerifiedSource(_registryChecker); + var candidate = SerializeBytes(request.Candidate); + var result = await _gitHubPublisher.PublishAsync( + target, + candidate, + NormalizeCommitMessage(request.CommitMessage), + async () => + { + var secondAuthorization = await _unsignedGate.AuthorizeAsync(registry, request.Candidate.PluginId, ct); + return secondAuthorization.Allowed ? null : secondAuthorization.Message; + }, + ct); + + if (result.Outcome is not (GitPublishOutcome.Published or GitPublishOutcome.PublishedPendingCdn)) + { + return Failure( + result.Outcome == GitPublishOutcome.CommittedNotPushed ? WorkflowErrorKind.Conflict : WorkflowErrorKind.Validation, + "githubIndexPublishFailed", + result.Message, + result.Outcome == GitPublishOutcome.CommittedNotPushed ? new[] { "indexCommitted" } : null); + } + + var publishedBytes = result.PublishedBytes ?? GitHubIndexPublisher.NormalizeToLf(candidate); + var sha = RecordPublishedBytes(request.ProjectPath, publishedBytes); + var phases = new[] { "indexPublished", "liveVerified" }; + return Success( + "indexPublished", + new IndexPublishResult(request.Candidate.PluginId, sha, preview.DestinationDescription, phases), + result.Message, + phases); + } + + private async Task> PublishServerAsync( + IndexPublishRequest request, + IndexPublishPreview preview, + CancellationToken ct) + { + var cfg = _config.GetServerUploadConfig(); + if (cfg is null) + return Failure(WorkflowErrorKind.Validation, "serverNotConfigured", "Server upload is not configured."); + + var candidate = SerializeBytes(request.Candidate); + var publish = await _coordinator.PublishAsync( + new ServerUploadPublishTransport(_server, cfg), + new RegistryVerifiedSource(_registryChecker), + new PublishRequest(request.Candidate.PluginId, candidate) + { + ConfirmOrdinary = false, + ChangeSummary = NormalizeCommitMessage(request.CommitMessage) + }, + _ => true, + ct); + + if (publish.Status == PublishStatus.NotSigned) + { + return await PublishUnsignedServerAsync( + request, + preview, + cfg, + candidate, + publish.VerifiedRegistryJson!, + ct); + } + + if (publish.Status is not (PublishStatus.Published or PublishStatus.AlreadyUpToDate or PublishStatus.Recovered) || + !publish.LocalSourceIsLive) + { + return Failure( + publish.Status == PublishStatus.Cancelled ? WorkflowErrorKind.Cancelled : WorkflowErrorKind.Conflict, + "serverIndexPublishFailed", + publish.Message); + } + + var sha = RecordPublishedBytes(request.ProjectPath, candidate); + var phases = new[] { "indexPublished", "liveVerified" }; + return Success( + "indexPublished", + new IndexPublishResult(request.Candidate.PluginId, sha, preview.DestinationDescription, phases), + publish.Message, + phases); + } + + private async Task> PublishUnsignedServerAsync( + IndexPublishRequest request, + IndexPublishPreview preview, + ServerUploadConfig cfg, + byte[] candidate, + string verifiedRegistryJson, + CancellationToken ct) + { + var registered = IndexProofService.TryReadIndexUrl(verifiedRegistryJson, request.Candidate.PluginId); + if (registered.IdCaseDiffers) + return Failure(WorkflowErrorKind.Conflict, "registryIdentityMismatch", "The registry spells this plugin id with different capitalisation."); + if (registered.Listed && registered.Url is null) + return Failure(WorkflowErrorKind.Conflict, "registeredIndexUrlMissing", "The registry lists this plugin but carries no usable index URL."); + if (registered.Url is { } address && + IndexPublishCoordinator.IndexUrlMismatch(address, request.Candidate.PluginId) is { } mismatch) + return Failure(WorkflowErrorKind.Conflict, "registeredIndexUrlMismatch", mismatch); + + try + { + await _server.PublishIndexAsync(cfg, request.Candidate.PluginId, candidate, beforeSwitchAsync: null, ct); + var readBack = await _server.ReadPluginIndexAsync(cfg, request.Candidate.PluginId, ct); + if (!readBack.Present || readBack.Bytes is null || !readBack.Bytes.AsSpan().SequenceEqual(candidate)) + { + return Failure( + WorkflowErrorKind.Conflict, + "liveReadBackMismatch", + "The index switched live, but the read-back bytes did not match the candidate.", + new[] { "indexPublished" }); + } + + var sha = RecordPublishedBytes(request.ProjectPath, candidate); + var phases = new[] { "indexPublished", "liveVerified" }; + return Success( + "indexPublished", + new IndexPublishResult(request.Candidate.PluginId, sha, preview.DestinationDescription, phases), + "Published the unsigned index and verified the exact live bytes.", + phases); + } + catch (IndexPublishFailedException ex) when (ex.RenameAttempted) + { + return Failure(WorkflowErrorKind.Conflict, "indexPublishInterrupted", ex.Message); + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Conflict, "indexPublishFailed", ex.Message); + } + } + + private async Task> ReconcileUnsignedAsync( + string projectPath, + PluginRepoIndex local, + byte[] localBytes, + bool dryRun, + bool confirmAdoption, + CancellationToken ct) + { + var destination = _config.GetPublishDestination(projectPath, local.PluginId); + byte[]? live = null; + try + { + if (destination == PublishDestination.Server) + { + var cfg = _config.GetServerUploadConfig(); + if (cfg is null) + return Success("catalogReconcileSkipped", local, "Server upload is not configured, so no unsigned live catalog was adopted."); + var remote = await _server.ReadPluginIndexAsync(cfg, local.PluginId, ct); + live = remote.Present ? remote.Bytes : null; + } + else if (destination == PublishDestination.GitHub) + { + var (target, _) = await _gitHubPublisher.ResolveTargetAsync(projectPath, ct); + if (target is null) + return Success("catalogReconcileSkipped", local, "The GitHub publication target could not be resolved, so no live catalog was adopted."); + using var response = await _http.GetAsync(target.BranchRawUrl, ct); + if (response.StatusCode is not (HttpStatusCode.NotFound or HttpStatusCode.Gone)) + { + response.EnsureSuccessStatusCode(); + live = await response.Content.ReadAsByteArrayAsync(ct); + } + } + else + { + return Success("catalogReconcileSkipped", local, "No publishing destination is selected, so no live catalog was adopted."); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return Failure("catalogLiveReadFailed", WorkflowErrorKind.Conflict, $"Couldn't read the live unsigned catalog: {ex.Message}", local); + } + + if (live is null || live.AsSpan().SequenceEqual(localBytes)) + return Success("catalogAlreadyCurrent", local, "The local catalog is already current."); + + PluginRepoIndex liveIndex; + try + { + liveIndex = Deserialize(live); + var report = Validate(liveIndex); + if (report.PublishBlockers.Count > 0) + throw new InvalidOperationException(string.Join(Environment.NewLine, report.PublishBlockers)); + if (!string.Equals(liveIndex.PluginId, local.PluginId, StringComparison.Ordinal)) + throw new InvalidOperationException("The live catalog belongs to a different plugin id."); + } + catch (Exception ex) + { + return Failure("catalogLiveInvalid", WorkflowErrorKind.Conflict, $"The live catalog couldn't be adopted safely: {ex.Message}", local); + } + + var localSha = Convert.ToHexStringLower(SHA256.HashData(localBytes)); + var lastPublished = _config.GetLastPublishedIndexSha(projectPath); + if ((lastPublished is null || !string.Equals(localSha, lastPublished, StringComparison.OrdinalIgnoreCase)) && + !confirmAdoption) + { + return Failure( + WorkflowErrorKind.Conflict, + "catalogAdoptionConfirmationRequired", + "The live catalog differs, and this folder contains changes that were never published. Adopting it would discard those changes.", + liveIndex); + } + + if (dryRun) + { + return Success( + "catalogReconcilePreviewed", + liveIndex, + "The newer live unsigned catalog would replace this folder's stale catalog."); + } + + var adoption = LocalIndexAdoption.ReplaceIfUnchanged( + IndexFileService.GetIndexPath(projectPath), + localBytes, + live, + out var error); + if (adoption != AdoptionResult.Replaced) + return Failure(WorkflowErrorKind.Conflict, "catalogAdoptionSuperseded", error ?? "index.json changed during reconciliation."); + + RecordPublishedBytes(projectPath, live); + return Success("catalogReconciled", liveIndex, "Adopted the newer live unsigned catalog."); + } + + private IReadOnlyList DescribeChanges(string projectPath, PluginRepoIndex candidate) + { + try + { + var current = _indexFiles.Load(projectPath); + var currentBytes = SerializeBytes(current); + var candidateBytes = SerializeBytes(candidate); + if (currentBytes.AsSpan().SequenceEqual(candidateBytes)) + return new[] { "No in-memory difference from the saved index." }; + return new[] + { + $"Games: {current.Games.Count} to {candidate.Games.Count}.", + $"Releases: {current.ReleasesByGameId.Values.Sum(list => list.Count)} to {candidate.ReleasesByGameId.Values.Sum(list => list.Count)}." + }; + } + catch + { + return new[] + { + $"Candidate contains {candidate.Games.Count} game(s) and {candidate.ReleasesByGameId.Values.Sum(list => list.Count)} release(s)." + }; + } + } + + private string RecordPublishedBytes(string projectPath, byte[] bytes) + { + var sha = Convert.ToHexStringLower(SHA256.HashData(bytes)); + _config.RecordRecent(projectPath, Path.GetFileName(projectPath)); + _config.SetLastPublishedIndexSha(projectPath, sha); + return sha; + } + + private static byte[] SerializeBytes(PluginRepoIndex candidate) => + Encoding.UTF8.GetBytes(SerializeText(candidate, trailingNewline: true)); + + private static string SerializeText(PluginRepoIndex candidate, bool trailingNewline) + { + var json = JsonSerializer.Serialize(candidate, JsonOptions); + return trailingNewline ? json + Environment.NewLine : json; + } + + private static PluginRepoIndex Deserialize(byte[] bytes) => + JsonSerializer.Deserialize(bytes, JsonOptions) + ?? throw new InvalidOperationException("index.json deserialized to null."); + + private static string NormalizeCommitMessage(string message) => + string.IsNullOrWhiteSpace(message) ? "Update accessibility mod index" : message.Trim(); + + private static WorkflowResult Success( + string status, + T value, + string message, + IReadOnlyList? completedPhases = null) => + new(status, value, new[] { message }, completedPhases: completedPhases); + + private static WorkflowResult Failure( + WorkflowErrorKind kind, + string status, + string message, + IReadOnlyList? completedPhases = null) => + new(status, default, new[] { message }, kind, completedPhases); + + private static WorkflowResult Failure( + WorkflowErrorKind kind, + string status, + string message, + T value) => + new(status, value, new[] { message }, kind); + + private static WorkflowResult Failure( + string status, + WorkflowErrorKind kind, + string message, + T value) => + new(status, value, new[] { message }, kind); +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/JsonPayloadService.cs b/src/AccessibilityModManager.Authoring/Workflows/JsonPayloadService.cs new file mode 100644 index 0000000..050ed5b --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/JsonPayloadService.cs @@ -0,0 +1,56 @@ +using System.Text; +using System.Text.Json; + +namespace AccessibilityModManager.Authoring.Workflows; + +public sealed class JsonPayloadService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public async Task ReadAsync(string source, TextReader stdin, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + ArgumentNullException.ThrowIfNull(stdin); + + ct.ThrowIfCancellationRequested(); + + var json = source == "-" + ? await stdin.ReadToEndAsync().WaitAsync(ct) + : await ReadFileAsync(source, ct); + + var value = JsonSerializer.Deserialize(json, JsonOptions); + if (value is null) + { + throw new InvalidOperationException( + $"JSON payload from {DescribeSource(source)} deserialized to null."); + } + + return value; + } + + private static async Task ReadFileAsync(string path, CancellationToken ct) + { + var fullPath = Path.GetFullPath(path); + + await using var stream = new FileStream( + fullPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + + using var reader = new StreamReader( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), + detectEncodingFromByteOrderMarks: true); + + return await reader.ReadToEndAsync().WaitAsync(ct); + } + + private static string DescribeSource(string source) => + source == "-" ? "standard input" : $"'{Path.GetFullPath(source)}'"; +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/PackageWorkflow.cs b/src/AccessibilityModManager.Authoring/Workflows/PackageWorkflow.cs new file mode 100644 index 0000000..4a64d43 --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/PackageWorkflow.cs @@ -0,0 +1,207 @@ +using System.IO.Compression; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using AccessibilityModManager.Infrastructure.Security; +using AccessibilityModManager.Infrastructure.Services; +using Serilog; + +namespace AccessibilityModManager.Authoring.Workflows; + +public sealed record PackageBuildRequest( + string SourceFolder, + string OutputZipPath, + string PluginId, + string GameId, + string Version, + IReadOnlyList Dependencies, + LifecycleScriptInputs Scripts); + +public sealed record PackageBuildPreview( + string SourceFolder, + string OutputZipPath, + string PluginId, + string GameId, + string Version, + int TopLevelEntryCount, + bool HasLifecycleScripts); + +public sealed record PackageInspection( + string ZipPath, + string Sha256, + int FileCount, + long TotalBytes, + PackageValidationReport Validation); + +public sealed class PackageWorkflow +{ + private readonly ManifestBuilderService _builder; + private readonly Sha256HashService _hashes; + private readonly ILogger _logger; + + public PackageWorkflow( + ManifestBuilderService builder, + Sha256HashService hashes, + ILogger logger) + { + _builder = builder ?? throw new ArgumentNullException(nameof(builder)); + _hashes = hashes ?? throw new ArgumentNullException(nameof(hashes)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public PackageBuildPreview PreviewBuild(PackageBuildRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ValidateIdentity(request.PluginId, request.GameId, request.Version); + ArgumentNullException.ThrowIfNull(request.Dependencies); + ArgumentNullException.ThrowIfNull(request.Scripts); + + var source = ManifestBuilderService.ValidateBuildInputs(request.SourceFolder, request.Scripts); + ArgumentException.ThrowIfNullOrWhiteSpace(request.OutputZipPath); + var output = Path.GetFullPath(request.OutputZipPath); + + if (Directory.Exists(output)) + throw new InvalidOperationException($"Package output path is a directory: '{output}'."); + if (!string.Equals(Path.GetExtension(output), ".zip", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Package output must use the .zip extension."); + if (PathSafety.IsContained(source, output)) + { + throw new InvalidOperationException( + "Package output cannot be inside the source folder because the ZIP would include or lock itself while being built."); + } + + return new PackageBuildPreview( + source, + output, + request.PluginId.Trim(), + request.GameId.Trim(), + request.Version.Trim(), + Directory.EnumerateFileSystemEntries(source, "*", SearchOption.TopDirectoryOnly).Count(), + request.Scripts.PreInstall is not null || + request.Scripts.PostInstall is not null || + request.Scripts.PostUninstall is not null); + } + + public async Task BuildAsync( + PackageBuildRequest request, + CancellationToken ct) + { + var preview = PreviewBuild(request); + ct.ThrowIfCancellationRequested(); + + try + { + await _builder.BuildPackageAsync( + preview.SourceFolder, + preview.GameId, + preview.PluginId, + preview.Version, + request.Dependencies.ToList(), + preview.OutputZipPath, + request.Scripts, + ct); + + var inspection = await ValidateAsync( + preview.OutputZipPath, + preview.PluginId, + preview.GameId, + preview.Version, + ct); + + if (!inspection.Validation.IsValid) + { + throw new InvalidOperationException( + "The finished package failed the manager's pre-publish validation:" + + Environment.NewLine + + string.Join(Environment.NewLine, inspection.Validation.Errors)); + } + + return inspection; + } + catch + { + TryDelete(preview.OutputZipPath); + throw; + } + } + + public async Task ValidateAsync( + string zipPath, + string expectedPluginId, + string expectedGameId, + string expectedVersion, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(zipPath); + ValidateIdentity(expectedPluginId, expectedGameId, expectedVersion); + + var fullPath = Path.GetFullPath(zipPath); + if (!File.Exists(fullPath)) + throw new FileNotFoundException($"Package ZIP not found: {fullPath}", fullPath); + + await using var stream = new FileStream( + fullPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 128 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + ct.ThrowIfCancellationRequested(); + var report = PluginPackageValidation.Validate( + stream, + expectedPluginId.Trim(), + expectedGameId.Trim(), + expectedVersion.Trim(), + _logger); + + var (fileCount, totalBytes) = InspectEntries(stream); + var sha256 = await _hashes.ComputeAsync(stream, ct); + + return new PackageInspection(fullPath, sha256, fileCount, totalBytes, report); + } + + private static (int FileCount, long TotalBytes) InspectEntries(Stream stream) + { + if (stream.CanSeek) + stream.Position = 0; + + try + { + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + var files = archive.Entries + .Where(entry => !entry.FullName.EndsWith("/", StringComparison.Ordinal)) + .ToArray(); + return (files.Length, files.Sum(entry => entry.Length)); + } + catch (InvalidDataException) + { + return (0, 0); + } + finally + { + if (stream.CanSeek) + stream.Position = 0; + } + } + + private static void ValidateIdentity(string pluginId, string gameId, string version) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + ArgumentException.ThrowIfNullOrWhiteSpace(gameId); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // Preserve the primary build or validation exception. A leftover failed package is + // still logged by the caller and will never be returned as publishable output. + } + } +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/PatreonWorkflow.cs b/src/AccessibilityModManager.Authoring/Workflows/PatreonWorkflow.cs new file mode 100644 index 0000000..e04b97e --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/PatreonWorkflow.cs @@ -0,0 +1,279 @@ +using System.Security.Cryptography; +using System.Text; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using AccessibilityModManager.Infrastructure.Patreon; + +namespace AccessibilityModManager.Authoring.Workflows; + +public sealed record PatreonSessionStatus( + bool IsSignedIn, + string? MemberName, + string? CampaignId); + +public sealed record PatreonTierInfo(string TierId, string DisplayName); + +public sealed record PatreonAttachmentInfo( + string SelectionId, + string FileName, + string? DownloadUrl) +{ + public long? SizeBytes { get; init; } + public IReadOnlyList RequiredTierIds { get; init; } = []; +} + +public sealed record PatreonPostInspection( + string PostId, + IReadOnlyList Attachments); + +public interface IPatreonAuthorSession +{ + bool IsSignedIn { get; } + PatreonAccount? CurrentAccount { get; } + PatreonOwnCampaign? OwnCampaign { get; } + Task LoadAsync(); + Task SignInAsync(CancellationToken ct); + Task SignOutAsync(CancellationToken ct); + Task RefreshOwnCampaignAsync(CancellationToken ct); + Task<(IReadOnlyList Attachments, string? DebugFilePath)> + ValidatePostUrlAsync(string postUrl, CancellationToken ct); +} + +public sealed class PatreonAuthorSession(PatreonAuthorService service) : IPatreonAuthorSession +{ + public bool IsSignedIn => service.IsSignedIn; + public PatreonAccount? CurrentAccount => service.CurrentAccount; + public PatreonOwnCampaign? OwnCampaign => service.OwnCampaign; + public Task LoadAsync() => service.LoadAsync(); + public Task SignInAsync(CancellationToken ct) => service.SignInAsync(ct); + public Task SignOutAsync(CancellationToken ct) => service.SignOutAsync(ct); + public Task RefreshOwnCampaignAsync(CancellationToken ct) => + service.RefreshOwnCampaignAsync(ct); + public Task<(IReadOnlyList Attachments, string? DebugFilePath)> + ValidatePostUrlAsync(string postUrl, CancellationToken ct) => + service.ValidatePostUrlAsync(postUrl, ct); +} + +public interface IPatreonWorkflow +{ + Task> GetStatusAsync(CancellationToken ct); + Task> SignInAsync(CancellationToken ct); + Task> SignOutAsync(CancellationToken ct); + Task>> GetTiersAsync(CancellationToken ct); + Task> InspectPostAsync(string postUrl, CancellationToken ct); +} + +public sealed class PatreonWorkflow(IPatreonAuthorSession session) : IPatreonWorkflow +{ + public async Task> GetStatusAsync(CancellationToken ct) + { + try + { + ct.ThrowIfCancellationRequested(); + await session.LoadAsync(); + return Success( + "patreonStatus", + Status(), + session.IsSignedIn + ? "Signed in to Patreon." + : "Not signed in to Patreon."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure( + WorkflowErrorKind.Authentication, + "patreonStatusFailed", + ex.Message); + } + } + + public async Task> SignInAsync(CancellationToken ct) + { + try + { + await session.SignInAsync(ct); + if (!session.IsSignedIn) + { + return Failure( + WorkflowErrorKind.Authentication, + "patreonSignInFailed", + "Patreon sign-in returned without an account."); + } + + return Success("patreonSignedIn", Status(), "Signed in to Patreon."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure( + WorkflowErrorKind.Authentication, + "patreonSignInFailed", + ex.Message); + } + } + + public async Task> SignOutAsync(CancellationToken ct) + { + try + { + await session.LoadAsync(); + await session.SignOutAsync(ct); + return Success("patreonSignedOut", true, "Signed out of Patreon and removed the saved author session."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Authentication, "patreonSignOutFailed", ex.Message); + } + } + + public async Task>> GetTiersAsync( + CancellationToken ct) + { + try + { + await session.LoadAsync(); + if (!session.IsSignedIn) + { + return Failure>( + WorkflowErrorKind.Authentication, + "patreonSignInRequired", + "Sign in to Patreon before loading your campaign tiers."); + } + + var campaign = await session.RefreshOwnCampaignAsync(ct); + if (campaign is null) + { + return Failure>( + WorkflowErrorKind.Validation, + "patreonCampaignMissing", + "The signed-in Patreon account has no creator campaign available."); + } + + IReadOnlyList tiers = campaign.Tiers + .Select(tier => new PatreonTierInfo(tier.Id, tier.DisplayLabel)) + .ToArray(); + return Success( + "patreonTiersListed", + tiers, + $"Found {tiers.Count} tier(s) for {campaign.DisplayName} ({campaign.CampaignId})."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure>( + WorkflowErrorKind.Authentication, + "patreonTierRefreshFailed", + ex.Message); + } + } + + public async Task> InspectPostAsync( + string postUrl, + CancellationToken ct) + { + var postId = PatreonAuthorService.ExtractPostId(postUrl); + if (postId is null) + { + return Failure( + WorkflowErrorKind.Validation, + "patreonPostUrlInvalid", + "Use a Patreon post URL whose final path segment ends with its numeric post id."); + } + + try + { + await session.LoadAsync(); + if (!session.IsSignedIn) + { + return Failure( + WorkflowErrorKind.Authentication, + "patreonSignInRequired", + "Sign in to Patreon before validating one of your posts."); + } + + var (attachments, diagnostic) = await session.ValidatePostUrlAsync(postUrl, ct); + if (attachments.Count == 0) + { + var message = diagnostic is null + ? "The post could not be read or contains no downloadable attachments." + : $"The post returned no downloadable attachments. A private diagnostic was written to {diagnostic}; review it before sharing."; + return Failure( + WorkflowErrorKind.Validation, + "patreonPostHasNoAttachments", + message); + } + + var mapped = attachments + .Select((attachment, ordinal) => new PatreonAttachmentInfo( + SelectionId(attachment, ordinal), + string.IsNullOrWhiteSpace(attachment.FileName) + ? $"attachment-{ordinal + 1}" + : attachment.FileName, + attachment.DownloadUrl?.AbsoluteUri) + { + SizeBytes = attachment.SizeBytes, + RequiredTierIds = attachment.RequiredTierIds.ToArray() + }) + .ToArray(); + return Success( + "patreonPostInspected", + new PatreonPostInspection(postId, mapped), + $"Found {mapped.Length} attachment(s) on Patreon post {postId}."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure( + WorkflowErrorKind.Authentication, + "patreonPostInspectionFailed", + ex.Message); + } + } + + private PatreonSessionStatus Status() + { + var account = session.CurrentAccount; + return new PatreonSessionStatus( + session.IsSignedIn, + account?.FullName ?? account?.Email, + session.OwnCampaign?.CampaignId); + } + + private static string SelectionId(PatreonPostAttachment attachment, int ordinal) + { + var material = string.Join( + "\n", + attachment.PostId, + attachment.FileName ?? string.Empty, + attachment.SizeBytes?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty, + string.Join(",", attachment.RequiredTierIds.OrderBy(value => value, StringComparer.Ordinal)), + ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(material)))[..24]; + } + + private static WorkflowResult Success(string status, T value, string message) => + new(status, value, new[] { message }); + + private static WorkflowResult Failure( + WorkflowErrorKind kind, + string status, + string message) => + new(status, default, new[] { message }, kind); +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/RegistryAdminWorkflow.cs b/src/AccessibilityModManager.Authoring/Workflows/RegistryAdminWorkflow.cs new file mode 100644 index 0000000..ff5c88c --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/RegistryAdminWorkflow.cs @@ -0,0 +1,541 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using AccessibilityModManager.Infrastructure.CatalogClaims; +using AccessibilityModManager.Infrastructure.Security; +using AccessibilityModManager.Infrastructure.Services; +using Serilog; + +namespace AccessibilityModManager.Authoring.Workflows; + +public sealed record RegistryAdminStatus( + bool Enabled, + string? RepositoryPath, + string? RegistryJsonPath); + +public sealed record RegistryDocumentResult(string Path, string Sha256, bool SignaturePresent); + +public sealed record RegistryJsonDocument(string Path, string Sha256, string Content); + +public sealed record RegistryPublishResult( + string Destination, + string JsonSha256, + string SignatureSha256, + IReadOnlyList CompletedPhases); + +public interface IRegistryAdminWorkflow +{ + WorkflowResult GetStatus(); + Task> OpenAsync(string? registryRepoPath, CancellationToken ct); + Task> RefreshAsync(string registryRepoPath, CancellationToken ct); + WorkflowResult ShowJson(string registryRepoOrJsonPath); + WorkflowResult Validate(string registryJsonPath); + WorkflowResult Save(string registryJsonPath, string content); + WorkflowResult Sign( + string registryJsonPath, + string privateKeyPath, + string passphrase, + bool confirmed); + Task> PublishAsync( + string registryRepoPath, + bool confirmed, + CancellationToken ct); + Task> CommitAsync( + string registryRepoPath, + string message, + CancellationToken ct); + Task> PushAsync(string registryRepoPath, CancellationToken ct); +} + +/// +/// Registry maintenance shared by the admin WPF build and CLI. Every public method checks the +/// compile-time gate before touching configuration, files, Git, keys, or the network. +/// +public sealed class RegistryAdminWorkflow( + AuthorConfigService config, + GitService git, + ServerUploadService server, + HttpClient http, + ILogger logger) : IRegistryAdminWorkflow +{ + private const string RegistryFileName = "plugin-registry.json"; + + public WorkflowResult GetStatus() + { + if (AdminRequired() is { } blocked) return blocked; + + try + { + var repo = config.Load().LastRegistryRepoPath ?? DefaultRepoPath(); + var json = FindRegistry(repo, requireExists: false); + return Success( + "registryAdminStatus", + new RegistryAdminStatus(true, repo, json), + json is null + ? $"Registry administration is enabled; no registry JSON was found in {repo}." + : $"Registry administration is enabled and {json} is available."); + } + catch (Exception ex) + { + return Failure("registryStatusFailed", ex.Message); + } + } + + public async Task> OpenAsync( + string? registryRepoPath, + CancellationToken ct) + { + if (AdminRequired() is { } blocked) return blocked; + + try + { + var repo = Path.GetFullPath(string.IsNullOrWhiteSpace(registryRepoPath) + ? DefaultRepoPath() + : registryRepoPath); + if (!await git.IsAvailableAsync(ct)) + return Failure("gitUnavailable", "Git for Windows is required."); + + if (!await git.IsRepoAsync(repo, ct)) + { + if (Directory.Exists(repo) && Directory.EnumerateFileSystemEntries(repo).Any()) + return Failure( + "registryRepoNotEmpty", + $"{repo} exists and is not an empty Git repository, so it was left alone."); + + var clone = await git.CloneAsync( + $"https://github.com/{RegistryMembershipChecker.RegistryRepo}.git", + repo, + ct); + if (!clone.Success) + return ProcessFailure("registryCloneFailed", clone); + } + + SaveRepoPath(repo); + var path = FindRegistry(repo, requireExists: true)!; + var validated = Validate(path); + return validated.ErrorKind == WorkflowErrorKind.None + ? new WorkflowResult( + "registryOpened", + validated.Value, + new[] { $"Opened and validated {path}." }) + : validated; + } + catch (Exception ex) + { + logger.Warning(ex, "Could not open the registry repository"); + return Failure("registryOpenFailed", ex.Message); + } + } + + public async Task> RefreshAsync( + string registryRepoPath, + CancellationToken ct) + { + if (AdminRequired() is { } blocked) return blocked; + + try + { + var repo = Path.GetFullPath(registryRepoPath); + if (!await git.IsRepoAsync(repo, ct)) + return Failure("registryRepoInvalid", $"{repo} is not a Git repository."); + var pull = await git.PullAsync(repo, ct); + if (!pull.Success) return ProcessFailure("registryRefreshFailed", pull); + SaveRepoPath(repo); + var validated = Validate(FindRegistry(repo, requireExists: true)!); + return validated.ErrorKind == WorkflowErrorKind.None + ? new WorkflowResult( + "registryRefreshed", validated.Value, new[] { "Pulled the registry repository and validated its JSON." }) + : validated; + } + catch (Exception ex) + { + return Failure("registryRefreshFailed", ex.Message); + } + } + + public WorkflowResult ShowJson(string registryRepoOrJsonPath) + { + if (AdminRequired() is { } blocked) return blocked; + + try + { + var path = ResolveRegistryPath(registryRepoOrJsonPath); + var bytes = File.ReadAllBytes(path); + return Success( + "registryJsonShown", + new RegistryJsonDocument(path, Sha(bytes), Encoding.UTF8.GetString(bytes)), + $"Read {path}."); + } + catch (Exception ex) + { + return Failure("registryJsonReadFailed", ex.Message); + } + } + + public WorkflowResult Validate(string registryJsonPath) + { + if (AdminRequired() is { } blocked) return blocked; + + try + { + var path = Path.GetFullPath(registryJsonPath); + var bytes = File.ReadAllBytes(path); + RequireNoBom(bytes); + var report = PluginRegistryValidation.Validate(Encoding.UTF8.GetString(bytes)); + if (!report.IsValid) + { + return new WorkflowResult( + "registryValidationFailed", + null, + report.Errors, + WorkflowErrorKind.Validation); + } + + return Success( + "registryValidated", + Document(path, bytes), + $"The registry passes the same validation rules used by the manager. SHA256 {Sha(bytes)}."); + } + catch (Exception ex) + { + return Failure("registryValidationFailed", ex.Message, WorkflowErrorKind.Validation); + } + } + + public WorkflowResult Save(string registryJsonPath, string content) + { + if (AdminRequired() is { } blocked) return blocked; + + try + { + using var _ = JsonDocument.Parse(content); + var path = Path.GetFullPath(registryJsonPath); + var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content); + DurableFile.Write(path, bytes); + return Success( + "registryJsonSaved", + Document(path, bytes), + $"Saved {path}. Its previous detached signature is now stale until the file is signed again."); + } + catch (Exception ex) + { + return Failure("registryJsonSaveFailed", ex.Message, WorkflowErrorKind.Validation); + } + } + + public WorkflowResult Sign( + string registryJsonPath, + string privateKeyPath, + string passphrase, + bool confirmed) + { + if (AdminRequired() is { } blocked) return blocked; + if (!confirmed) + return Failure( + "confirmationRequired", + "Signing the registry requires --yes after its validated hash has been reviewed."); + + try + { + var validation = Validate(registryJsonPath); + if (validation.ErrorKind != WorkflowErrorKind.None || validation.Value is null) return validation; + + var path = validation.Value.Path; + var bytes = File.ReadAllBytes(path); + using var rsa = RSA.Create(); + rsa.ImportFromEncryptedPem(File.ReadAllText(Path.GetFullPath(privateKeyPath)), passphrase); + ClaimKeyPolicy.Require(rsa); + var fingerprint = ClaimTrustContext.PublicKeyFingerprint(rsa.ExportSubjectPublicKeyInfoPem()); + if (!string.Equals(fingerprint, RegistryTrustKey.ExpectedFingerprint, StringComparison.OrdinalIgnoreCase)) + { + return Failure( + "registryKeyMismatch", + $"That private key's public fingerprint is {fingerprint}, not the manager's registry trust key. Nothing was signed.", + WorkflowErrorKind.Authentication); + } + + var signature = Convert.ToBase64String(rsa.SignData( + bytes, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pss)); + DurableFile.Write(path + ".sig", Encoding.UTF8.GetBytes(signature)); + return Success( + "registrySigned", + Document(path, bytes), + $"Signed the exact registry bytes and wrote {path}.sig."); + } + catch (CryptographicException) + { + return Failure( + "registrySigningFailed", + "The private key could not be opened. The passphrase may be wrong or the key file may be damaged.", + WorkflowErrorKind.Authentication); + } + catch (Exception ex) + { + return Failure("registrySigningFailed", ex.Message); + } + } + + public async Task> PublishAsync( + string registryRepoPath, + bool confirmed, + CancellationToken ct) + { + if (AdminRequired() is { } blocked) return blocked; + if (!confirmed) + return Failure( + "confirmationRequired", + "Publishing the registry requires --yes after reviewing the exact validated hashes."); + + var phases = new List(); + try + { + var path = ResolveRegistryPath(registryRepoPath); + var validation = Validate(path); + if (validation.ErrorKind != WorkflowErrorKind.None || validation.Value is null) + return Forward(validation, phases); + + var jsonBytes = File.ReadAllBytes(path); + var sigPath = path + ".sig"; + var sigBytes = File.ReadAllBytes(sigPath); + VerifyRegistryPair(jsonBytes, sigBytes); + phases.Add("localPairVerified"); + + var serverConfig = config.GetServerUploadConfig() + ?? throw new InvalidOperationException("Server upload is not configured."); + var (liveJson, liveSignature) = await server.ReadPublishedRegistryAsync(serverConfig, ct); + phases.Add("livePairInspected"); + + if (liveJson is not null && liveJson.AsSpan().SequenceEqual(jsonBytes)) + { + if (liveSignature is null) throw new InvalidOperationException("The live registry has no signature."); + VerifyRegistryPair(liveJson, liveSignature); + phases.Add("alreadyLive"); + return PublishSuccess(serverConfig, jsonBytes, sigBytes, phases, + "The live registry pair is already byte-identical and valid."); + } + + RequireVersionMovesForward(jsonBytes, liveJson); + await server.PublishRegistryPairAsync(serverConfig, jsonBytes, sigBytes, CancellationToken.None); + phases.Add("pairUploaded"); + + var (readBackJson, readBackSignature) = await server.ReadPublishedRegistryAsync( + serverConfig, + CancellationToken.None); + if (readBackJson is null || readBackSignature is null || + !readBackJson.AsSpan().SequenceEqual(jsonBytes) || + !readBackSignature.AsSpan().SequenceEqual(sigBytes)) + { + throw new InvalidOperationException( + "The registry pair read back from the server differs from the exact files uploaded."); + } + VerifyRegistryPair(readBackJson, readBackSignature); + phases.Add("serverReadBackVerified"); + + await VerifyPublicPairAsync(jsonBytes, CancellationToken.None); + phases.Add("publicReadBackVerified"); + return PublishSuccess(serverConfig, jsonBytes, sigBytes, phases, + "Published the signed registry pair and verified the exact public bytes."); + } + catch (Exception ex) + { + logger.Error(ex, "Registry publication failed after {Phases}", string.Join(",", phases)); + return new WorkflowResult( + "registryPublishFailed", + null, + new[] { ex.Message }, + WorkflowErrorKind.Conflict, + phases); + } + } + + public async Task> CommitAsync( + string registryRepoPath, + string message, + CancellationToken ct) + { + if (AdminRequired() is { } blocked) return blocked; + + try + { + var repo = Path.GetFullPath(registryRepoPath); + if (!await git.IsRepoAsync(repo, ct)) + return Failure("registryRepoInvalid", $"{repo} is not a Git repository."); + var status = await git.StatusPorcelainAsync(repo, ct); + if (!status.Success) return ProcessFailure("registryGitStatusFailed", status); + if (string.IsNullOrWhiteSpace(status.Stdout)) + return Success("registryCommitNotNeeded", status, "The registry working tree is clean."); + var add = await git.AddAsync(repo, ".", ct); + if (!add.Success) return ProcessFailure("registryGitAddFailed", add); + var commit = await git.CommitAsync( + repo, + string.IsNullOrWhiteSpace(message) ? "Update plugin registry" : message.Trim(), + ct); + return commit.Success + ? Success("registryCommitted", commit, "Committed the registry repository locally.") + : ProcessFailure("registryCommitFailed", commit); + } + catch (Exception ex) + { + return Failure("registryCommitFailed", ex.Message); + } + } + + public async Task> PushAsync( + string registryRepoPath, + CancellationToken ct) + { + if (AdminRequired() is { } blocked) return blocked; + + try + { + var repo = Path.GetFullPath(registryRepoPath); + if (!await git.IsRepoAsync(repo, ct)) + return Failure("registryRepoInvalid", $"{repo} is not a Git repository."); + var push = await git.PushAsync(repo, ct); + return push.Success + ? Success("registryPushed", push, + "Pushed registry Git history. This does not change what managers read; registry publish does.") + : ProcessFailure("registryPushFailed", push); + } + catch (Exception ex) + { + return Failure("registryPushFailed", ex.Message); + } + } + + private string DefaultRepoPath() => Path.Combine( + config.StorageDirectory, + "repos", + RegistryMembershipChecker.RegistryRepo.Replace('/', '-')); + + private void SaveRepoPath(string repo) + { + var current = config.Load(); + current.LastRegistryRepoPath = repo; + config.Save(current); + } + + private static string ResolveRegistryPath(string path) + { + var full = Path.GetFullPath(path); + if (Directory.Exists(full)) + return FindRegistry(full, requireExists: true)!; + return full; + } + + private static string? FindRegistry(string repo, bool requireExists) + { + var candidates = new[] + { + Path.Combine(repo, RegistryFileName), + Path.Combine(repo, "registry.json") + }; + var found = candidates.FirstOrDefault(File.Exists); + if (found is null && requireExists) + throw new FileNotFoundException($"No {RegistryFileName} or registry.json was found in {repo}."); + return found; + } + + private static RegistryDocumentResult Document(string path, byte[] bytes) => + new(path, Sha(bytes), File.Exists(path + ".sig")); + + private static string Sha(byte[] bytes) => + Convert.ToHexStringLower(SHA256.HashData(bytes)); + + private static void RequireNoBom(byte[] bytes) + { + if (bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) + throw new InvalidOperationException("The registry starts with a UTF-8 byte-order mark, which managers do not accept."); + } + + private static void VerifyRegistryPair(byte[] json, byte[] signatureFile) + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(RegistryTrustKey.PublicKeyPem); + var signature = Convert.FromBase64String(Encoding.UTF8.GetString(signatureFile).Trim()); + if (!rsa.VerifyData(json, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pss)) + throw new InvalidOperationException("The detached signature does not verify over the exact registry JSON bytes."); + } + + private static void RequireVersionMovesForward(byte[] candidate, byte[]? live) + { + if (live is null) return; + var candidateVersion = ReadVersion(candidate); + var liveVersion = ReadVersion(live); + if (VersionComparer.Instance.Compare(candidateVersion, liveVersion) <= 0) + { + throw new InvalidOperationException( + $"The live registry is version {liveVersion}; changed content must raise registryVersion above it, not publish version {candidateVersion}."); + } + } + + private static string ReadVersion(byte[] json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.GetProperty("registryVersion").GetString() + ?? throw new InvalidOperationException("registryVersion is null."); + } + + private async Task VerifyPublicPairAsync(byte[] expectedJson, CancellationToken ct) + { + var cacheBust = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var jsonUri = new Uri(RegistryMembershipChecker.RegistryUrl.AbsoluteUri + "?_=" + cacheBust); + var sigUri = new Uri(RegistryMembershipChecker.RegistryUrl.AbsoluteUri + ".sig?_=" + cacheBust); + var publicJson = await http.GetByteArrayAsync(jsonUri, ct); + var publicSignature = await http.GetByteArrayAsync(sigUri, ct); + if (!publicJson.AsSpan().SequenceEqual(expectedJson)) + throw new InvalidOperationException("The public registry bytes differ from what was uploaded."); + VerifyRegistryPair(publicJson, publicSignature); + } + + private static RegistryPublishResult PublishValue( + ServerUploadConfig cfg, + byte[] json, + byte[] signature, + IReadOnlyList phases) => + new($"{cfg.Host}:{cfg.RemoteCatalogRoot}", Sha(json), Sha(signature), phases); + + private static WorkflowResult PublishSuccess( + ServerUploadConfig cfg, + byte[] json, + byte[] signature, + IReadOnlyList phases, + string message) => + new("registryPublished", PublishValue(cfg, json, signature, phases), new[] { message }, + completedPhases: phases); + + private static WorkflowResult? AdminRequired() => + AuthoringBuildFlags.IsRegistryAdmin + ? null + : new WorkflowResult( + "registryAdminBuildRequired", + default, + new[] + { + "Registry administration requires an admin build. This ordinary build exposes the commands for discovery but cannot read private admin configuration or perform registry work." + }, + WorkflowErrorKind.Authentication); + + private static WorkflowResult Success(string status, T value, string message) => + new(status, value, new[] { message }); + + private static WorkflowResult Failure( + string status, + string message, + WorkflowErrorKind kind = WorkflowErrorKind.Conflict) => + new(status, default, new[] { message }, kind); + + private static WorkflowResult ProcessFailure(string status, ProcessResult process) => + Failure(status, string.IsNullOrWhiteSpace(process.Combined) + ? $"The process exited with code {process.ExitCode}." + : process.Combined); + + private static WorkflowResult Forward( + WorkflowResult result, + IReadOnlyList? phases = null) => + new(result.Status, default, result.Messages, result.ErrorKind, phases ?? result.CompletedPhases); +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/ReleaseWorkflow.cs b/src/AccessibilityModManager.Authoring/Workflows/ReleaseWorkflow.cs new file mode 100644 index 0000000..43b3a70 --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/ReleaseWorkflow.cs @@ -0,0 +1,814 @@ +using System.Security.Cryptography; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using AccessibilityModManager.Infrastructure.Security; +using AccessibilityModManager.Infrastructure.Services; +using Serilog; + +namespace AccessibilityModManager.Authoring.Workflows; + +public sealed record ReleasePublishRequest( + string ProjectPath, + string PluginId, + string GameId, + string Version, + string Channel, + string SourceRepo, + string LocalZipPath, + string? AssetFileName, + string? Notes, + string? ChangelogUrl, + PatreonGate? Patreon); + +public sealed record ReleasePublishPreview( + string Repository, + string Tag, + string AssetFileName, + string Sha256, + bool CreatesRelease, + bool ReplacesAsset) +{ + public ReleaseAssetDestination Destination { get; init; } = ReleaseAssetDestination.GitHub; + public string DestinationDescription { get; init; } = $"GitHub repository {Repository}, tag {Tag}"; +} + +public sealed record ReleasePublishResult( + ModRelease Release, + string AssetUrl, + string Sha256, + IReadOnlyList CompletedPhases); + +public sealed record PackageStageRequest( + string PluginId, + string GameId, + string Version, + string LocalZipPath, + string? AssetFileName); + +public interface IReleaseWorkflow +{ + Task> StagePackageAsync(PackageStageRequest request, CancellationToken ct); + Task> PreviewAsync(ReleasePublishRequest request, CancellationToken ct); + Task> PrepareAsync(ReleasePublishRequest request, CancellationToken ct); + Task> PublishAsync( + PreparedRelease prepared, + ReleasePublishRequest request, + bool confirmed, + CancellationToken ct); +} + +public sealed class PreparedRelease : IAsyncDisposable +{ + private readonly string _tempDirectory; + private bool _disposed; + + internal PreparedRelease( + string tempDirectory, + string stagedPath, + FileStream stream, + string sha256, + ReleasePublishPreview preview, + ReleasePublishRequest? request, + PackageStageRequest packageRequest, + bool assetAlreadyMatches) + { + _tempDirectory = tempDirectory; + StagedPath = stagedPath; + Stream = stream; + Sha256 = sha256; + Preview = preview; + Request = request; + PackageRequest = packageRequest; + AssetAlreadyMatches = assetAlreadyMatches; + } + + public ReleasePublishPreview Preview { get; } + public string StagedPath { get; } + public string Sha256 { get; } + public FileStream Stream { get; } + + internal ReleasePublishRequest? Request { get; } + internal PackageStageRequest PackageRequest { get; } + internal bool AssetAlreadyMatches { get; } + + public ValueTask DisposeAsync() + { + if (_disposed) + return ValueTask.CompletedTask; + + _disposed = true; + Stream.Dispose(); + TryDelete(_tempDirectory); + return ValueTask.CompletedTask; + } + + internal void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(PreparedRelease)); + } + + private static void TryDelete(string directory) + { + try + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} + +public sealed class ReleaseWorkflow : IReleaseWorkflow +{ + private readonly IGitHubService _gitHub; + private readonly IPublishedAssetProbe _assets; + private readonly ILogger _logger; + + public ReleaseWorkflow( + IGitHubService gitHub, + IPublishedAssetProbe assets, + ILogger logger) + { + _gitHub = gitHub ?? throw new ArgumentNullException(nameof(gitHub)); + _assets = assets ?? throw new ArgumentNullException(nameof(assets)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> StagePackageAsync( + PackageStageRequest request, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + try + { + ArgumentException.ThrowIfNullOrWhiteSpace(request.PluginId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.GameId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Version); + ArgumentException.ThrowIfNullOrWhiteSpace(request.LocalZipPath); + + var localRequest = new ReleasePublishRequest( + ProjectPath: Path.GetTempPath(), + PluginId: request.PluginId.Trim(), + GameId: request.GameId.Trim(), + Version: request.Version.Trim(), + Channel: "stable", + SourceRepo: "local/stage", + LocalZipPath: Path.GetFullPath(request.LocalZipPath), + AssetFileName: NullIfBlank(request.AssetFileName), + Notes: null, + ChangelogUrl: null, + Patreon: null); + var staged = Stage(localRequest); + try + { + ct.ThrowIfCancellationRequested(); + var report = PluginPackageValidation.Validate( + staged.Stream, + localRequest.PluginId, + localRequest.GameId, + localRequest.Version, + _logger); + if (!report.IsValid) + { + await staged.DisposeAsync(); + return new WorkflowResult( + "packageValidationFailed", + null, + new[] { "The manager would refuse this package." }.Concat(report.Errors).ToArray(), + WorkflowErrorKind.Validation, + new[] { "packageStaged" }); + } + + var prepared = new PreparedRelease( + Path.GetDirectoryName(staged.StagedPath)!, + staged.StagedPath, + staged.Stream, + staged.Sha256, + staged.Preview, + request: null, + new PackageStageRequest( + localRequest.PluginId, + localRequest.GameId, + localRequest.Version, + localRequest.LocalZipPath, + staged.Preview.AssetFileName), + assetAlreadyMatches: false); + staged.Detach(); + return new WorkflowResult( + "packageStaged", + prepared, + new[] { $"Prepared and validated {prepared.Preview.AssetFileName}; SHA256 {prepared.Sha256}." }, + completedPhases: new[] { "packageStaged", "packageValidated" }); + } + catch + { + await staged.DisposeAsync(); + throw; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + return Failure(WorkflowErrorKind.Validation, "packageStagingFailed", ex.Message); + } + } + + public async Task> PreviewAsync( + ReleasePublishRequest request, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + var normalized = NormalizeRequest(request); + if (normalized.Patreon is not null) + { + return Failure( + WorkflowErrorKind.Validation, + "releaseValidationFailed", + "A Patreon-gated package cannot be uploaded to a public GitHub release. Use the server or Patreon flow instead."); + } + + if (!await _gitHub.IsAvailableAsync(ct)) + return Failure(WorkflowErrorKind.Authentication, "githubUnavailable", "GitHub CLI isn't installed or available on PATH."); + if (!await _gitHub.IsAuthenticatedAsync(ct)) + return Failure(WorkflowErrorKind.Authentication, "githubAuthenticationRequired", "GitHub CLI isn't signed in. Run 'gh auth login' and try again."); + + var isPrivate = await _gitHub.IsRepoPrivateAsync(normalized.SourceRepo, ct); + if (isPrivate is true) + { + return Failure( + WorkflowErrorKind.Validation, + "privateRepositoryRefused", + $"Repository '{normalized.SourceRepo}' is private. Its release assets cannot be downloaded anonymously by the mod manager."); + } + if (isPrivate is null) + { + return Failure( + WorkflowErrorKind.Conflict, + "repositoryVisibilityUnknown", + $"Couldn't verify whether '{normalized.SourceRepo}' is public. Nothing was uploaded."); + } + + if (!File.Exists(normalized.LocalZipPath)) + throw new FileNotFoundException($"The wrapped ZIP isn't there: {normalized.LocalZipPath}", normalized.LocalZipPath); + + var fileName = PathSafety.EnsureLeafFileName( + string.IsNullOrWhiteSpace(normalized.AssetFileName) + ? Path.GetFileName(normalized.LocalZipPath) + : normalized.AssetFileName, + "Asset filename"); + await using var stream = new FileStream( + normalized.LocalZipPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 128 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var report = PluginPackageValidation.Validate( + stream, + normalized.PluginId, + normalized.GameId, + normalized.Version, + _logger); + if (!report.IsValid) + { + return new WorkflowResult( + "packageValidationFailed", + null, + new[] { "The manager would refuse this package." }.Concat(report.Errors).ToArray(), + WorkflowErrorKind.Validation); + } + + stream.Position = 0; + var sha = Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, ct)); + var tag = $"v{normalized.Version}"; + var releases = await _gitHub.ListReleasesAsync(normalized.SourceRepo, ct: ct); + var hasTag = releases.Any(release => string.Equals(release.TagName, tag, StringComparison.Ordinal)); + var replacesAsset = false; + if (hasTag) + { + var url = GitHubService.BuildAssetUrl(normalized.SourceRepo, tag, fileName); + var state = await _assets.ProbeAsync(url, ct); + if (state.Status == PublishedAssetStatus.Unreadable || + state.Status == PublishedAssetStatus.Found && string.IsNullOrWhiteSpace(state.Sha256)) + { + return Failure( + WorkflowErrorKind.Conflict, + "publishedAssetUnreadable", + $"The release tag '{tag}' exists, but the current asset couldn't be read safely. Nothing was uploaded."); + } + + replacesAsset = state.Status == PublishedAssetStatus.Found && + !string.Equals(state.Sha256, sha, StringComparison.OrdinalIgnoreCase); + } + + var preview = new ReleasePublishPreview( + normalized.SourceRepo, + tag, + fileName, + sha, + CreatesRelease: !hasTag, + ReplacesAsset: replacesAsset); + return new WorkflowResult( + "releasePreviewed", + preview, + new[] + { + $"Release upload is valid and would target {preview.Repository} {preview.Tag} as {preview.AssetFileName}." + }); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + return Failure(WorkflowErrorKind.Validation, "releasePreviewFailed", ex.Message); + } + } + + public async Task> PrepareAsync( + ReleasePublishRequest request, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + var normalized = NormalizeRequest(request); + if (normalized.Patreon is not null) + { + return Failure( + WorkflowErrorKind.Validation, + "releaseValidationFailed", + "A Patreon-gated package cannot be uploaded to a public GitHub release. Use the server or Patreon flow instead."); + } + + if (!await _gitHub.IsAvailableAsync(ct)) + { + return Failure( + WorkflowErrorKind.Authentication, + "githubUnavailable", + "GitHub CLI isn't installed or available on PATH."); + } + + if (!await _gitHub.IsAuthenticatedAsync(ct)) + { + return Failure( + WorkflowErrorKind.Authentication, + "githubAuthenticationRequired", + "GitHub CLI isn't signed in. Run 'gh auth login' and try again."); + } + + var isPrivate = await _gitHub.IsRepoPrivateAsync(normalized.SourceRepo, ct); + if (isPrivate is true) + { + return Failure( + WorkflowErrorKind.Validation, + "privateRepositoryRefused", + $"Repository '{normalized.SourceRepo}' is private. Its release assets cannot be downloaded anonymously by the mod manager."); + } + + if (isPrivate is null) + { + return Failure( + WorkflowErrorKind.Conflict, + "repositoryVisibilityUnknown", + $"Couldn't verify whether '{normalized.SourceRepo}' is public. Nothing was staged or uploaded."); + } + + var prepared = Stage(normalized); + try + { + var report = PluginPackageValidation.Validate( + prepared.Stream, + normalized.PluginId, + normalized.GameId, + normalized.Version, + _logger); + if (!report.IsValid) + { + await prepared.DisposeAsync(); + return new WorkflowResult( + "packageValidationFailed", + null, + new[] { "The manager would refuse this package." }.Concat(report.Errors).ToArray(), + WorkflowErrorKind.Validation, + new[] { "packageStaged" }); + } + + var releases = await _gitHub.ListReleasesAsync(normalized.SourceRepo, ct: ct); + var hasTag = releases.Any(release => + string.Equals(release.TagName, prepared.Preview.Tag, StringComparison.Ordinal)); + var assetAlreadyMatches = false; + var replacesAsset = false; + + if (hasTag) + { + var assetUrl = GitHubService.BuildAssetUrl( + normalized.SourceRepo, + prepared.Preview.Tag, + prepared.Preview.AssetFileName); + var published = await _assets.ProbeAsync(assetUrl, ct); + if (published.Status == PublishedAssetStatus.Unreadable || + published.Status == PublishedAssetStatus.Found && string.IsNullOrWhiteSpace(published.Sha256)) + { + await prepared.DisposeAsync(); + return new WorkflowResult( + "publishedAssetUnreadable", + null, + new[] + { + $"The release tag '{prepared.Preview.Tag}' exists, but the current asset couldn't be read safely. Nothing was uploaded." + }, + WorkflowErrorKind.Conflict, + new[] { "packageStaged", "packageValidated" }); + } + + if (published.Status == PublishedAssetStatus.Found) + { + assetAlreadyMatches = string.Equals( + published.Sha256, + prepared.Sha256, + StringComparison.OrdinalIgnoreCase); + replacesAsset = !assetAlreadyMatches; + } + } + + var preview = prepared.Preview with + { + CreatesRelease = !hasTag, + ReplacesAsset = replacesAsset + }; + var ready = new PreparedRelease( + Path.GetDirectoryName(prepared.StagedPath)!, + prepared.StagedPath, + prepared.Stream, + prepared.Sha256, + preview, + normalized, + new PackageStageRequest( + normalized.PluginId, + normalized.GameId, + normalized.Version, + normalized.LocalZipPath, + preview.AssetFileName), + assetAlreadyMatches); + prepared.Detach(); + + return new WorkflowResult( + "releasePrepared", + ready, + new[] + { + $"Prepared {preview.AssetFileName} for {preview.Repository} {preview.Tag}; SHA256 {preview.Sha256}." + }, + completedPhases: new[] { "packageStaged", "packageValidated" }); + } + catch + { + await prepared.DisposeAsync(); + throw; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + _logger.Warning(ex, "Release preparation failed"); + return Failure(WorkflowErrorKind.Validation, "releasePreparationFailed", ex.Message); + } + } + + public async Task> PublishAsync( + PreparedRelease prepared, + ReleasePublishRequest request, + bool confirmed, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(prepared); + ArgumentNullException.ThrowIfNull(request); + prepared.ThrowIfDisposed(); + + var normalized = NormalizeRequest(request); + if (prepared.Request is null || !Equivalent(prepared.Request, normalized)) + { + return Failure( + WorkflowErrorKind.Conflict, + "preparedReleaseMismatch", + "The release request changed after the package was staged. Prepare it again so the validated bytes and metadata remain bound together.", + new[] { "packageStaged", "packageValidated" }); + } + + if (prepared.Preview.ReplacesAsset && !confirmed) + { + return Failure( + WorkflowErrorKind.Conflict, + "confirmationRequired", + $"Publishing would replace {prepared.Preview.AssetFileName} on {prepared.Preview.Repository} {prepared.Preview.Tag}. Confirm that exact replacement before uploading.", + new[] { "packageStaged", "packageValidated" }); + } + + var phases = new List { "packageStaged", "packageValidated" }; + var notes = string.IsNullOrWhiteSpace(normalized.Notes) + ? $"Release {prepared.Preview.Tag} for the Accessibility Mod Manager." + : normalized.Notes!; + + try + { + if (prepared.Preview.CreatesRelease) + { + var created = await _gitHub.CreateReleaseAsync( + prepared.Preview.Repository, + prepared.Preview.Tag, + prepared.Preview.Tag, + notes, + new[] { prepared.StagedPath }, + ct); + if (!created.Success) + { + return Failure( + WorkflowErrorKind.Conflict, + "githubReleaseCreateFailed", + $"GitHub release creation failed: {created.Combined}", + phases); + } + + phases.Add("githubReleaseCreated"); + } + else + { + if (prepared.AssetAlreadyMatches) + { + phases.Add("githubAssetAlreadyMatched"); + } + else + { + var uploaded = await _gitHub.UploadReleaseAssetAsync( + prepared.Preview.Repository, + prepared.Preview.Tag, + prepared.StagedPath, + clobber: prepared.Preview.ReplacesAsset, + ct); + if (!uploaded.Success) + { + return Failure( + WorkflowErrorKind.Conflict, + "githubAssetUploadFailed", + $"GitHub asset upload failed: {uploaded.Combined}", + phases); + } + + phases.Add("githubAssetUploaded"); + } + + if (!string.IsNullOrWhiteSpace(normalized.Notes)) + { + var edited = await _gitHub.EditReleaseNotesAsync( + prepared.Preview.Repository, + prepared.Preview.Tag, + notes, + ct); + if (!edited.Success) + { + return Failure( + WorkflowErrorKind.Conflict, + "githubNotesUpdateFailed", + $"The asset phase completed, but GitHub release notes couldn't be updated: {edited.Combined}", + phases); + } + + phases.Add("githubNotesUpdated"); + } + } + + var assetUrl = GitHubService.BuildAssetUrl( + prepared.Preview.Repository, + prepared.Preview.Tag, + prepared.Preview.AssetFileName); + var release = new ModRelease + { + GameId = normalized.GameId, + PluginId = normalized.PluginId, + Version = normalized.Version, + Channel = normalized.Channel, + PackageUrl = assetUrl, + Sha256 = prepared.Sha256, + ChangelogUrl = NullIfBlank(normalized.ChangelogUrl), + Notes = NullIfBlank(normalized.Notes), + Patreon = null + }; + var result = new ReleasePublishResult( + release, + assetUrl.AbsoluteUri, + prepared.Sha256, + phases.ToArray()); + + return new WorkflowResult( + "releaseUploaded", + result, + new[] { $"Published {prepared.Preview.AssetFileName} at {assetUrl}." }, + completedPhases: result.CompletedPhases); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "GitHub release publication failed after phases {Phases}", phases); + return Failure( + WorkflowErrorKind.Conflict, + "githubReleasePublishFailed", + ex.Message, + phases); + } + } + + private static ReleasePublishRequest NormalizeRequest(ReleasePublishRequest request) + { + ArgumentException.ThrowIfNullOrWhiteSpace(request.ProjectPath); + ArgumentException.ThrowIfNullOrWhiteSpace(request.PluginId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.GameId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Version); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Channel); + ArgumentException.ThrowIfNullOrWhiteSpace(request.SourceRepo); + ArgumentException.ThrowIfNullOrWhiteSpace(request.LocalZipPath); + + if (request.ChangelogUrl is { Length: > 0 } changelog && + (!Uri.TryCreate(changelog, UriKind.Absolute, out var changelogUri) || + !string.Equals(changelogUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException("Changelog URL must be an absolute https:// URL."); + } + + return request with + { + ProjectPath = Path.GetFullPath(request.ProjectPath), + PluginId = request.PluginId.Trim(), + GameId = request.GameId.Trim(), + Version = request.Version.Trim(), + Channel = request.Channel.Trim(), + SourceRepo = NormalizeRepo(request.SourceRepo), + LocalZipPath = Path.GetFullPath(request.LocalZipPath), + AssetFileName = NullIfBlank(request.AssetFileName), + Notes = NullIfBlank(request.Notes), + ChangelogUrl = NullIfBlank(request.ChangelogUrl) + }; + } + + private static string NormalizeRepo(string repo) + { + var value = repo.Trim(); + if (Uri.TryCreate(value, UriKind.Absolute, out var uri)) + { + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("GitHub repository URLs must use https://github.com/owner/name."); + } + + value = uri.AbsolutePath.Trim('/'); + } + + if (value.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + value = value[..^4]; + var parts = value.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 2 || parts.Any(string.IsNullOrWhiteSpace)) + throw new InvalidOperationException("GitHub repository must be written as owner/name."); + return $"{parts[0]}/{parts[1]}"; + } + + private static MutablePreparedRelease Stage(ReleasePublishRequest request) + { + if (!File.Exists(request.LocalZipPath)) + { + throw new FileNotFoundException( + $"The wrapped ZIP isn't there: {request.LocalZipPath}", + request.LocalZipPath); + } + + var fileName = PathSafety.EnsureLeafFileName( + string.IsNullOrWhiteSpace(request.AssetFileName) + ? Path.GetFileName(request.LocalZipPath) + : request.AssetFileName, + "Asset filename"); + var tempDirectory = Path.Combine( + Path.GetTempPath(), + "AccessibilityModManager.AuthorTool", + "publish", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + try + { + var stagedPath = Path.Combine(tempDirectory, fileName); + File.Copy(request.LocalZipPath, stagedPath); + var stream = new FileStream(stagedPath, FileMode.Open, FileAccess.Read, FileShare.Read); + try + { + var sha = Convert.ToHexStringLower(SHA256.HashData(stream)); + stream.Position = 0; + var tag = $"v{request.Version}"; + var preview = new ReleasePublishPreview( + request.SourceRepo, + tag, + fileName, + sha, + CreatesRelease: false, + ReplacesAsset: false); + return new MutablePreparedRelease(tempDirectory, stagedPath, stream, sha, preview, request); + } + catch + { + stream.Dispose(); + throw; + } + } + catch + { + TryDelete(tempDirectory); + throw; + } + } + + private static bool Equivalent(ReleasePublishRequest left, ReleasePublishRequest right) => + left == right; + + private static string? NullIfBlank(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static WorkflowResult Failure( + WorkflowErrorKind kind, + string status, + string message, + IReadOnlyList? completedPhases = null) => + new(status, default, new[] { message }, kind, completedPhases); + + private static void TryDelete(string directory) + { + try + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private sealed class MutablePreparedRelease : IAsyncDisposable + { + private readonly string _tempDirectory; + private bool _detached; + + public MutablePreparedRelease( + string tempDirectory, + string stagedPath, + FileStream stream, + string sha256, + ReleasePublishPreview preview, + ReleasePublishRequest request) + { + _tempDirectory = tempDirectory; + StagedPath = stagedPath; + Stream = stream; + Sha256 = sha256; + Preview = preview; + Request = request; + } + + public string StagedPath { get; } + public FileStream Stream { get; } + public string Sha256 { get; } + public ReleasePublishPreview Preview { get; } + public ReleasePublishRequest Request { get; } + + public void Detach() => _detached = true; + + public ValueTask DisposeAsync() + { + if (_detached) + return ValueTask.CompletedTask; + Stream.Dispose(); + TryDelete(_tempDirectory); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/ServerWorkflow.cs b/src/AccessibilityModManager.Authoring/Workflows/ServerWorkflow.cs new file mode 100644 index 0000000..ddcf129 --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/ServerWorkflow.cs @@ -0,0 +1,802 @@ +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; + +namespace AccessibilityModManager.Authoring.Workflows; + +public sealed record ServerConfigurationInput( + ServerUploadConfig Config, + string KeyPassphrase); + +public sealed record ServerConfigurationStatus( + bool IsConfigured, + string? Host, + int? Port, + string? User, + string? HostKeyFingerprint, + string? PrivateKeyPath, + bool HasKeyPassphrase, + string? RemoteBasePath, + string? RemoteCatalogRoot, + string? RemoteLockRoot, + string? PublicBaseUrl); + +public sealed record ServerConnectionReport( + bool Connected, + IReadOnlyList Steps); + +public sealed record ServerReleaseRequest( + string GameId, + string Version, + string AssetFileName, + string LocalZipPath, + PatreonGate? Gate); + +public sealed record ServerReleaseInspection( + string GameId, + string Version, + string AssetFileName, + string Sha256, + string PublicUrl, + ServerUploadService.RemoteReleaseState Remote); + +public sealed record ServerReleasePublishResult( + string GameId, + string Version, + string AssetFileName, + string Sha256, + ServerUploadService.ReleasePublishOutcome Outcome); + +public interface IServerAuthorTransport +{ + Task TestAsync(ServerUploadConfig config, CancellationToken ct); + Task> SelfTestAsync( + ServerUploadConfig config, + string pluginId, + CancellationToken ct); + Task InspectReleaseAsync( + ServerUploadConfig config, + ServerReleaseRequest request, + Stream package, + string sha256, + CancellationToken ct); + Task PublishReleaseAsync( + ServerUploadConfig config, + ServerReleaseRequest request, + Stream package, + string sha256, + CancellationToken ct); + Task GateExistsAsync( + ServerUploadConfig config, + string gameId, + string version, + CancellationToken ct); + Task PublishGateAsync( + ServerUploadConfig config, + string gameId, + string version, + PatreonGate gate, + CancellationToken ct); + Task RemoveGateAsync( + ServerUploadConfig config, + string gameId, + string version, + CancellationToken ct); + Task InspectLockAsync( + ServerUploadConfig config, + string pluginId, + CancellationToken ct); + Task BreakLockAsync( + ServerUploadConfig config, + string pluginId, + string expectedFingerprint, + CancellationToken ct); +} + +public sealed class ServerAuthorTransport( + ServerUploadService server, + RegistryMembershipChecker registry) : IServerAuthorTransport +{ + public async Task TestAsync( + ServerUploadConfig config, + CancellationToken ct) + { + var error = await server.TestConnectionAsync(config, ct); + return new ServerConnectionReport( + error is null, + new[] + { + new ServerCheckStep( + "Connect and verify writable paths", + error is null, + error ?? "Authenticated with the pinned host key and verified the configured paths.") + }); + } + + public Task> SelfTestAsync( + ServerUploadConfig config, + string pluginId, + CancellationToken ct) + { + var transport = new ServerUploadPublishTransport(server, config); + return ServerSelfTest.RunAsync( + transport, + pluginId, + ct, + rehearsal: transport, + registry: new RegistryVerifiedSource(registry)); + } + + public Task InspectReleaseAsync( + ServerUploadConfig config, + ServerReleaseRequest request, + Stream package, + string sha256, + CancellationToken ct) => + server.ProbeReleaseAsync( + config, + request.GameId, + request.Version, + request.AssetFileName, + package, + sha256, + ct); + + public Task PublishReleaseAsync( + ServerUploadConfig config, + ServerReleaseRequest request, + Stream package, + string sha256, + CancellationToken ct) => + server.PublishReleaseAsync( + config, + request.GameId, + request.Version, + request.AssetFileName, + package, + sha256, + request.Gate, + ct); + + public Task GateExistsAsync( + ServerUploadConfig config, + string gameId, + string version, + CancellationToken ct) => + server.GateExistsAsync(config, gameId, version, ct); + + public Task PublishGateAsync( + ServerUploadConfig config, + string gameId, + string version, + PatreonGate gate, + CancellationToken ct) => + server.PublishGateOnlyAsync(config, gameId, version, gate, ct); + + public Task RemoveGateAsync( + ServerUploadConfig config, + string gameId, + string version, + CancellationToken ct) => + server.RemoveGateAsync(config, gameId, version, ct); + + public Task InspectLockAsync( + ServerUploadConfig config, + string pluginId, + CancellationToken ct) => + server.ReadPublishLockAsync(config, pluginId, ct); + + public Task BreakLockAsync( + ServerUploadConfig config, + string pluginId, + string expectedFingerprint, + CancellationToken ct) => + server.BreakPublishLockAsync(config, pluginId, expectedFingerprint, ct); +} + +public interface IServerWorkflow +{ + WorkflowResult GetStatus(); + WorkflowResult Configure(ServerConfigurationInput input, bool dryRun); + WorkflowResult Clear(bool confirmed, bool dryRun); + Task> TestAsync(CancellationToken ct); + Task> SelfTestAsync(string pluginId, CancellationToken ct); + Task> InspectReleaseAsync( + string pluginId, + ServerReleaseRequest request, + CancellationToken ct); + Task> InspectPreparedReleaseAsync( + string pluginId, + ServerReleaseRequest request, + PreparedRelease package, + CancellationToken ct); + Task> UploadReleaseAsync( + string pluginId, + ServerReleaseRequest request, + bool confirmed, + bool dryRun, + CancellationToken ct); + Task> UploadPreparedReleaseAsync( + string pluginId, + ServerReleaseRequest request, + PreparedRelease package, + bool confirmed, + bool dryRun, + CancellationToken ct); + Task> SetGateAsync( + string gameId, + string version, + PatreonGate gate, + bool confirmed, + bool dryRun, + CancellationToken ct); + Task> RemoveGateAsync( + string gameId, + string version, + bool confirmed, + bool dryRun, + CancellationToken ct); + Task> InspectLockAsync( + string pluginId, + CancellationToken ct); + Task> BreakLockAsync( + string pluginId, + string expectedFingerprint, + bool confirmed, + bool dryRun, + CancellationToken ct); +} + +public sealed class ServerWorkflow( + AuthorConfigService config, + IServerAuthorTransport transport, + IReleaseWorkflow releases) : IServerWorkflow +{ + public WorkflowResult GetStatus() + { + var current = config.GetServerUploadConfig(); + return Success( + "serverStatus", + Describe(current), + current is null ? "Server upload is not configured." : $"Server upload is configured for {current.Host}."); + } + + public WorkflowResult Configure( + ServerConfigurationInput input, + bool dryRun) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(input.Config); + var normalized = NormalizeConfig(input.Config, input.KeyPassphrase); + var validation = ValidateConfig(normalized, requirePublicUrl: true); + if (validation is not null) + return Failure(WorkflowErrorKind.Validation, "serverConfigurationInvalid", validation); + + if (!dryRun) + config.SaveServerUploadConfig(normalized); + return Success( + dryRun ? "serverConfigurationPreviewed" : "serverConfigured", + Describe(normalized), + dryRun + ? $"The server configuration for {normalized.Host} is valid and would be saved." + : $"Saved the server configuration for {normalized.Host}."); + } + + public WorkflowResult Clear(bool confirmed, bool dryRun) + { + if (!dryRun && !confirmed) + return Failure(WorkflowErrorKind.Conflict, "confirmationRequired", "Clearing the saved server configuration requires confirmation."); + if (!dryRun) + config.SaveServerUploadConfig(null); + return Success( + dryRun ? "serverClearPreviewed" : "serverCleared", + true, + dryRun ? "The saved server configuration would be removed." : "Removed the saved server configuration."); + } + + public async Task> TestAsync(CancellationToken ct) + { + var current = RequireConfig(); + if (current.Failure is not null) return current.Failure; + try + { + var report = await transport.TestAsync(current.Config!, ct); + return report.Connected + ? Success("serverConnectionPassed", report, "The server connection and configured paths passed.") + : new WorkflowResult( + "serverConnectionFailed", + report, + report.Steps.Where(step => !step.Ok).Select(step => step.Detail).ToArray(), + WorkflowErrorKind.Authentication); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Authentication, "serverConnectionFailed", ex.Message); + } + } + + public async Task> SelfTestAsync( + string pluginId, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + var current = RequireConfig(); + if (current.Failure is not null) return current.Failure; + try + { + var steps = await transport.SelfTestAsync(current.Config!, pluginId, ct); + var report = new ServerConnectionReport(steps.Count > 0 && steps.All(step => step.Ok), steps); + return report.Connected + ? Success("serverSelfTestPassed", report, "Every server publishing self-test step passed.") + : new WorkflowResult( + "serverSelfTestFailed", + report, + steps.Where(step => !step.Ok).Select(step => $"{step.Name}: {step.Detail}").ToArray(), + WorkflowErrorKind.Conflict); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Conflict, "serverSelfTestFailed", ex.Message); + } + } + + public async Task> InspectReleaseAsync( + string pluginId, + ServerReleaseRequest request, + CancellationToken ct) + { + var validation = ValidateReleaseRequest(request); + if (validation is not null) + return Failure(WorkflowErrorKind.Validation, "serverReleaseInvalid", validation); + + var staged = await releases.StagePackageAsync( + new PackageStageRequest(pluginId, request.GameId, request.Version, request.LocalZipPath, request.AssetFileName), + ct); + if (staged.ErrorKind != WorkflowErrorKind.None || staged.Value is null) + return ForwardFailure(staged); + + await using var package = staged.Value; + return await InspectPreparedReleaseAsync(pluginId, request, package, ct); + } + + public async Task> InspectPreparedReleaseAsync( + string pluginId, + ServerReleaseRequest request, + PreparedRelease package, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(package); + package.ThrowIfDisposed(); + var current = RequireConfig(); + if (current.Failure is not null) return current.Failure; + var validation = ValidatePreparedRelease(pluginId, request, package); + if (validation is not null) + return Failure(WorkflowErrorKind.Conflict, "preparedReleaseMismatch", validation); + + try + { + var normalized = request with { AssetFileName = package.Preview.AssetFileName }; + package.Stream.Position = 0; + var remote = await transport.InspectReleaseAsync( + current.Config!, + normalized, + package.Stream, + package.Sha256, + ct); + return Success( + "serverReleaseInspected", + new ServerReleaseInspection( + normalized.GameId, + normalized.Version, + normalized.AssetFileName, + package.Sha256, + ServerUploadService.BuildPublicUrl( + current.Config!, + normalized.GameId, + normalized.Version, + normalized.AssetFileName), + remote), + DescribeRemote(remote)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Conflict, "serverReleaseInspectionFailed", ex.Message); + } + } + + public async Task> UploadReleaseAsync( + string pluginId, + ServerReleaseRequest request, + bool confirmed, + bool dryRun, + CancellationToken ct) + { + var validation = ValidateReleaseRequest(request); + if (validation is not null) + return Failure(WorkflowErrorKind.Validation, "serverReleaseInvalid", validation); + var staged = await releases.StagePackageAsync( + new PackageStageRequest(pluginId, request.GameId, request.Version, request.LocalZipPath, request.AssetFileName), + ct); + if (staged.ErrorKind != WorkflowErrorKind.None || staged.Value is null) + return ForwardFailure(staged); + + await using var package = staged.Value; + return await UploadPreparedReleaseAsync(pluginId, request, package, confirmed, dryRun, ct); + } + + public async Task> UploadPreparedReleaseAsync( + string pluginId, + ServerReleaseRequest request, + PreparedRelease package, + bool confirmed, + bool dryRun, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(package); + package.ThrowIfDisposed(); + var inspection = await InspectPreparedReleaseAsync(pluginId, request, package, ct); + if (inspection.ErrorKind != WorkflowErrorKind.None || inspection.Value is null) + return ForwardFailure(inspection); + + var remote = inspection.Value.Remote; + if (remote.OtherAssets.Count > 0) + { + return Failure( + WorkflowErrorKind.Conflict, + "serverVersionFolderOccupied", + $"The version folder already contains another package: {string.Join(", ", remote.OtherAssets)}."); + } + if (remote.PackageExists && !remote.PackageMatches) + { + return Failure( + WorkflowErrorKind.Conflict, + "serverReleaseImmutable", + "This version already exists with different bytes. Bump the version instead of replacing it."); + } + + if (dryRun) + { + return Success( + "serverReleaseUploadPreviewed", + new ServerReleasePublishResult( + inspection.Value.GameId, + inspection.Value.Version, + inspection.Value.AssetFileName, + inspection.Value.Sha256, + new ServerUploadService.ReleasePublishOutcome(false, false, false, false, string.Empty)), + "The exact validated package is safe to publish; dry run changed nothing."); + } + if (!confirmed) + { + return Failure( + WorkflowErrorKind.Conflict, + "confirmationRequired", + "Server release upload requires confirmation after inspecting the exact version folder."); + } + + var current = RequireConfig(); + if (current.Failure is not null) return current.Failure; + try + { + var normalized = request with { AssetFileName = package.Preview.AssetFileName }; + package.Stream.Position = 0; + var outcome = await transport.PublishReleaseAsync( + current.Config!, + normalized, + package.Stream, + package.Sha256, + ct); + return Success( + "serverReleaseUploaded", + new ServerReleasePublishResult( + normalized.GameId, + normalized.Version, + normalized.AssetFileName, + package.Sha256, + outcome), + outcome.PackageUploaded + ? $"Published {normalized.AssetFileName} and verified SHA256 {package.Sha256}." + : $"The server already held the same {normalized.AssetFileName}; no package bytes changed."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Conflict, "serverReleaseUploadFailed", ex.Message); + } + } + + public async Task> SetGateAsync( + string gameId, + string version, + PatreonGate gate, + bool confirmed, + bool dryRun, + CancellationToken ct) + { + var gateError = ValidateGate(gate); + if (gateError is not null) + return Failure(WorkflowErrorKind.Validation, "patreonGateInvalid", gateError); + if (!dryRun && !confirmed) + return Failure(WorkflowErrorKind.Conflict, "confirmationRequired", "Changing the server's Patreon tiers requires confirmation."); + + var current = RequireConfig(); + if (current.Failure is not null) return current.Failure; + try + { + if (!dryRun) + await transport.PublishGateAsync(current.Config!, gameId, version, gate, ct); + return Success( + dryRun ? "serverGateSetPreviewed" : "serverGateSet", + true, + dryRun + ? $"The gate for {gameId} {version} would be updated." + : $"Updated the gate for {gameId} {version}."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Conflict, "serverGateSetFailed", ex.Message); + } + } + + public async Task> RemoveGateAsync( + string gameId, + string version, + bool confirmed, + bool dryRun, + CancellationToken ct) + { + if (!dryRun && !confirmed) + return Failure(WorkflowErrorKind.Conflict, "confirmationRequired", "Removing the Patreon gate makes this version public and requires confirmation."); + var current = RequireConfig(); + if (current.Failure is not null) return current.Failure; + try + { + var exists = await transport.GateExistsAsync(current.Config!, gameId, version, ct); + if (!dryRun && exists) + await transport.RemoveGateAsync(current.Config!, gameId, version, ct); + return Success( + dryRun ? "serverGateRemovePreviewed" : "serverGateRemoved", + exists, + exists + ? dryRun + ? $"The gate for {gameId} {version} would be removed, making it public." + : $"Removed the gate for {gameId} {version}; it is public now." + : $"No gate exists for {gameId} {version}; nothing changed."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Conflict, "serverGateRemoveFailed", ex.Message); + } + } + + public async Task> InspectLockAsync( + string pluginId, + CancellationToken ct) + { + var current = RequireConfig(); + if (current.Failure is not null) return current.Failure; + try + { + var remote = await transport.InspectLockAsync(current.Config!, pluginId, ct); + return Success( + "serverLockInspected", + remote, + remote.Present + ? $"A publish lock is present with fingerprint {remote.Fingerprint}." + : "No publish lock is present."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Conflict, "serverLockInspectionFailed", ex.Message); + } + } + + public async Task> BreakLockAsync( + string pluginId, + string expectedFingerprint, + bool confirmed, + bool dryRun, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expectedFingerprint); + var current = RequireConfig(); + if (current.Failure is not null) return current.Failure; + + var inspected = await InspectLockAsync(pluginId, ct); + if (inspected.ErrorKind != WorkflowErrorKind.None || inspected.Value.Fingerprint is null) + return ForwardFailure(inspected); + if (!inspected.Value.Present || + !string.Equals(inspected.Value.Fingerprint, expectedFingerprint, StringComparison.Ordinal)) + { + return Failure(WorkflowErrorKind.Conflict, "serverLockChanged", "The current lock does not match the supplied fingerprint and was left alone."); + } + if (dryRun) + return Success("serverLockBreakPreviewed", true, "The exact displayed server lock would be removed."); + if (!confirmed) + return Failure(WorkflowErrorKind.Conflict, "confirmationRequired", "Breaking a server publish lock requires confirmation."); + + try + { + var removed = await transport.BreakLockAsync( + current.Config!, + pluginId, + expectedFingerprint, + ct); + return removed + ? Success("serverLockBroken", true, "Removed the exact displayed server lock.") + : Failure(WorkflowErrorKind.Conflict, "serverLockChanged", "The server lock changed before removal and was left alone."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return Failure(WorkflowErrorKind.Conflict, "serverLockBreakFailed", ex.Message); + } + } + + private (ServerUploadConfig? Config, WorkflowResult? Failure) RequireConfig() + { + var current = config.GetServerUploadConfig(); + return current is null + ? (null, Failure(WorkflowErrorKind.Authentication, "serverNotConfigured", "Server upload is not configured.")) + : (current, null); + } + + private static string? ValidateReleaseRequest(ServerReleaseRequest request) + { + if (request is null) return "A server release request is required."; + if (string.IsNullOrWhiteSpace(request.GameId)) return "Game id is required."; + if (string.IsNullOrWhiteSpace(request.Version)) return "Version is required."; + if (string.IsNullOrWhiteSpace(request.AssetFileName)) return "Asset filename is required."; + if (string.IsNullOrWhiteSpace(request.LocalZipPath)) return "Local package path is required."; + if (!File.Exists(request.LocalZipPath)) return $"Package not found at {request.LocalZipPath}."; + return request.Gate is null ? null : ValidateGate(request.Gate); + } + + private static string? ValidatePreparedRelease( + string pluginId, + ServerReleaseRequest request, + PreparedRelease package) + { + if (request is null) return "A server release request is required."; + var gateError = request.Gate is null ? null : ValidateGate(request.Gate); + if (gateError is not null) return gateError; + + var staged = package.PackageRequest; + if (!string.Equals(staged.PluginId, pluginId.Trim(), StringComparison.Ordinal) || + !string.Equals(staged.GameId, request.GameId.Trim(), StringComparison.Ordinal) || + !string.Equals(staged.Version, request.Version.Trim(), StringComparison.Ordinal) || + !string.Equals(package.Preview.AssetFileName, request.AssetFileName.Trim(), StringComparison.Ordinal)) + { + return "The server request does not match the plugin, game, version, and asset name used to validate the staged package."; + } + + return null; + } + + private static string? ValidateGate(PatreonGate gate) + { + if (gate is null) return "A Patreon gate is required."; + if (string.IsNullOrWhiteSpace(gate.CampaignId)) return "Patreon campaign id is required."; + if (gate.TierIds.Count == 0 || gate.TierIds.Any(string.IsNullOrWhiteSpace)) + return "At least one nonempty Patreon tier id is required."; + if (gate.TierIds.Distinct(StringComparer.Ordinal).Count() != gate.TierIds.Count) + return "Patreon tier ids must be unique."; + return null; + } + + private static ServerUploadConfig NormalizeConfig(ServerUploadConfig source, string passphrase) => + new() + { + Host = source.Host?.Trim() ?? string.Empty, + HostKeyFingerprint = NullIfBlank(source.HostKeyFingerprint), + User = source.User?.Trim() ?? string.Empty, + PrivateKeyPath = string.IsNullOrWhiteSpace(source.PrivateKeyPath) + ? string.Empty + : Path.GetFullPath(source.PrivateKeyPath.Trim()), + KeyPassphrase = passphrase ?? string.Empty, + KeyPassphraseProtected = false, + RemoteBasePath = source.RemoteBasePath?.Trim() ?? string.Empty, + RemoteCatalogRoot = source.RemoteCatalogRoot?.Trim() ?? string.Empty, + RemoteLockRoot = source.RemoteLockRoot?.Trim() ?? string.Empty, + PublicBaseUrl = source.PublicBaseUrl?.Trim().TrimEnd('/') ?? string.Empty, + Port = source.Port == 0 ? 22 : source.Port + }; + + private static string? ValidateConfig(ServerUploadConfig value, bool requirePublicUrl) + { + if (string.IsNullOrWhiteSpace(value.Host)) return "Server host is required."; + if (value.Port is < 1 or > 65535) return "SFTP port must be between 1 and 65535."; + if (string.IsNullOrWhiteSpace(value.HostKeyFingerprint)) return "A verified SSH host-key fingerprint is required."; + if (string.IsNullOrWhiteSpace(value.User)) return "Server user is required."; + if (string.IsNullOrWhiteSpace(value.PrivateKeyPath)) return "SSH private key path is required."; + if (!File.Exists(value.PrivateKeyPath)) return $"SSH private key file not found at '{value.PrivateKeyPath}'."; + if (string.IsNullOrWhiteSpace(value.RemoteBasePath) || !value.RemoteBasePath.StartsWith('/')) + return "Remote releases path must be an absolute POSIX path."; + if (string.IsNullOrWhiteSpace(value.RemoteCatalogRoot) || !value.RemoteCatalogRoot.StartsWith('/')) + return "Remote catalog path must be an absolute POSIX path."; + if (!string.IsNullOrWhiteSpace(value.RemoteLockRoot) && !value.RemoteLockRoot.StartsWith('/')) + return "An explicit remote lock path must be an absolute POSIX path."; + if (requirePublicUrl && + (!Uri.TryCreate(value.PublicBaseUrl, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) + { + return "Public download base URL must be an absolute https:// URL."; + } + return null; + } + + private static ServerConfigurationStatus Describe(ServerUploadConfig? value) => + value is null + ? new ServerConfigurationStatus(false, null, null, null, null, null, false, null, null, null, null) + : new ServerConfigurationStatus( + true, + value.Host, + value.Port, + value.User, + value.HostKeyFingerprint, + value.PrivateKeyPath, + !string.IsNullOrEmpty(value.KeyPassphrase), + value.RemoteBasePath, + value.RemoteCatalogRoot, + value.RemoteLockRoot, + value.PublicBaseUrl); + + private static string DescribeRemote(ServerUploadService.RemoteReleaseState remote) + { + if (remote.OtherAssets.Count > 0) + return $"The version folder contains another package: {string.Join(", ", remote.OtherAssets)}."; + if (!remote.PackageExists) + return remote.GateExists + ? "No package exists, but a Patreon gate file is present." + : "This version is not published on the server yet."; + return remote.PackageMatches + ? remote.GateExists + ? "The exact package already exists and is Patreon-gated." + : "The exact package already exists and is public." + : "This version exists with different package bytes."; + } + + private static string? NullIfBlank(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static WorkflowResult ForwardFailure(WorkflowResult result) => + new(result.Status, default, result.Messages, result.ErrorKind, result.CompletedPhases); + + private static WorkflowResult Success(string status, T value, string message) => + new(status, value, new[] { message }); + + private static WorkflowResult Failure( + WorkflowErrorKind kind, + string status, + string message) => + new(status, default, new[] { message }, kind); +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/SigningWorkflow.cs b/src/AccessibilityModManager.Authoring/Workflows/SigningWorkflow.cs new file mode 100644 index 0000000..7baede9 --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/SigningWorkflow.cs @@ -0,0 +1,494 @@ +using System.Text.Json; +using AccessibilityModManager.AuthorTool.Services; +using AccessibilityModManager.Core.Models; +using AccessibilityModManager.Infrastructure.CatalogClaims; +using AccessibilityModManager.Infrastructure.Security; +using Serilog; + +namespace AccessibilityModManager.Authoring.Workflows; + +public sealed record SigningKeyStatus( + string PluginId, + string KeyId, + string PublicKeyFingerprint, + bool ImportedFromBackup, + bool HasPublisherHead) +{ + /// The public half authors copy into the signed registry. It is not secret. + public string? PublicKeyPem { get; init; } +} + +public sealed record ClaimPublishPreview( + string PluginId, + string KeyId, + long PublishNumber, + IReadOnlyList Changes, + string DeletionsToken); + +/// A narrow source for the two trust-bearing documents claim signing needs. +public interface ISigningCatalogSource +{ + Task ReadVerifiedRegistryAsync(string pluginId, CancellationToken ct); + Task ReadLiveIndexAsync(string pluginId, CancellationToken ct); + + Task PublishExactIndexAsync( + string pluginId, + byte[] indexJson, + string expectedTrustContext, + CancellationToken ct) => + throw new NotSupportedException("This signing source is read-only."); +} + +/// Production implementation backed by the signed registry and configured SFTP server. +public sealed class SigningCatalogSource( + RegistryMembershipChecker registryChecker, + ServerUploadService server, + AuthorConfigService config) : ISigningCatalogSource +{ + public Task ReadVerifiedRegistryAsync(string pluginId, CancellationToken ct) => + new RegistryVerifiedSource(registryChecker).ReadVerifiedAsync(pluginId, ct); + + public Task ReadLiveIndexAsync( + string pluginId, + CancellationToken ct) => + server.ReadPluginIndexAsync(RequireConfig(), pluginId, ct); + + public async Task PublishExactIndexAsync( + string pluginId, + byte[] indexJson, + string expectedTrustContext, + CancellationToken ct) + { + var transport = new ServerUploadPublishTransport(server, RequireConfig()); + var handle = await transport.AcquireLockAsync(pluginId, ct); + try + { + await transport.PublishIndexAsync( + pluginId, + indexJson, + async () => + { + var registry = await ReadVerifiedRegistryAsync(pluginId, CancellationToken.None); + var resolution = IndexProofService.ResolveAnchor(registry, pluginId); + if (resolution.Status != IndexTrustStatus.Anchored || resolution.Anchor is null || + !string.Equals( + ClaimTrustContext.Compute(resolution.Anchor), + expectedTrustContext, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The registry changed before the interrupted publish could be resumed. " + + "The prepared bytes were not switched live."); + } + }, + CancellationToken.None); + } + finally + { + var release = await transport.ReleaseLockAsync(handle, CancellationToken.None); + if (release == PublishLockRelease.NotOurs) + { + throw new InvalidOperationException( + "The server publish lock changed while the interrupted publish was being resumed."); + } + } + } + + private ServerUploadConfig RequireConfig() => + config.GetServerUploadConfig() + ?? throw new InvalidOperationException( + "Server upload is not configured. Configure it before reading or resuming signed catalogs."); +} + +public interface ISigningWorkflow +{ + WorkflowResult GetStatus(string pluginId); + WorkflowResult Create(string pluginId, string passphrase); + WorkflowResult Export(string pluginId, string destination, string exportPassphrase); + WorkflowResult Import(string source, string importPassphrase); + WorkflowResult ChangePassphrase( + string pluginId, + string currentPassphrase, + string newPassphrase); + Task> PreviewClaimsAsync(string projectPath, CancellationToken ct); + Task> SignClaimsAsync( + string projectPath, + string deletionsToken, + bool confirmed, + CancellationToken ct); + WorkflowResult> GetHeadStatus(string pluginId); + Task> ConfirmHeadAsync(string projectPath, CancellationToken ct); + Task> CommitPendingAsync( + string projectPath, + bool confirmed, + CancellationToken ct); + Task> ResumeHeadAsync( + string projectPath, + bool confirmed, + CancellationToken ct); +} + +/// +/// Headless facade over the existing key, proof, and publisher-journal services. No private-key +/// format, claim validation, or replay decision is duplicated here. +/// +public sealed class SigningWorkflow( + ClaimSigningKeyStore keys, + PublisherHeadStore heads, + IndexProofService proofs, + IndexFileService indexFiles, + ISigningCatalogSource source, + ILogger logger) : ISigningWorkflow +{ + public WorkflowResult GetStatus(string pluginId) + { + try + { + var signing = keys.TryGet(pluginId); + if (signing is null) + { + return Success( + "signingKeyAbsent", + new SigningKeyStatus(pluginId, "", "", false, false), + $"No signing key is stored for '{pluginId}'."); + } + + return Success("signingKeyStatus", Status(signing), + $"Signing key '{signing.KeyId}' is stored for '{pluginId}'."); + } + catch (Exception ex) + { + return Failure("signingStatusFailed", ex.Message); + } + } + + public WorkflowResult Create(string pluginId, string passphrase) + { + try + { + var signing = keys.Create(pluginId, passphrase); + return Success( + "signingKeyCreated", + Status(signing), + $"Created signing key '{signing.KeyId}' for '{pluginId}'. Export a backup before publishing."); + } + catch (Exception ex) + { + logger.Warning(ex, "Could not create signing key for {PluginId}", pluginId); + return Failure( + "signingKeyCreateFailed", ex.Message, WorkflowErrorKind.Validation); + } + } + + public WorkflowResult Export( + string pluginId, + string destination, + string exportPassphrase) + { + try + { + var fullDestination = Path.GetFullPath(destination); + keys.Export(pluginId, fullDestination, exportPassphrase); + return Success("signingKeyExported", fullDestination, + $"Wrote the encrypted signing-key backup to {fullDestination}."); + } + catch (Exception ex) + { + logger.Warning(ex, "Could not export signing key for {PluginId}", pluginId); + return Failure( + "signingKeyExportFailed", ex.Message, WorkflowErrorKind.Validation); + } + } + + public WorkflowResult Import(string sourcePath, string importPassphrase) + { + try + { + var fullSource = Path.GetFullPath(sourcePath); + using var document = JsonDocument.Parse(File.ReadAllText(fullSource), new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow + }); + var pluginId = document.RootElement.GetProperty("pluginId").GetString(); + if (string.IsNullOrWhiteSpace(pluginId)) + throw new InvalidOperationException("That backup has no pluginId."); + + var expectedFingerprint = keys.TryGet(pluginId)?.PublicKeyFingerprint; + var signing = keys.Import(fullSource, importPassphrase, pluginId, expectedFingerprint); + return Success( + "signingKeyImported", + Status(signing), + $"Restored signing key '{signing.KeyId}' for '{pluginId}'. Its publishing history remains unconfirmed until a live publish is checked."); + } + catch (Exception ex) + { + logger.Warning(ex, "Could not import a signing-key backup"); + return Failure( + "signingKeyImportFailed", ex.Message, WorkflowErrorKind.Validation); + } + } + + public WorkflowResult ChangePassphrase( + string pluginId, + string currentPassphrase, + string newPassphrase) + { + try + { + keys.ChangePassphrase(pluginId, currentPassphrase, newPassphrase); + var signing = keys.TryGet(pluginId) + ?? throw new InvalidOperationException("The signing key disappeared after its passphrase changed."); + return Success("signingPassphraseChanged", Status(signing), + $"Changed the local passphrase for signing key '{signing.KeyId}'."); + } + catch (Exception ex) + { + logger.Warning(ex, "Could not change signing passphrase for {PluginId}", pluginId); + return Failure( + "signingPassphraseChangeFailed", ex.Message, WorkflowErrorKind.Validation); + } + } + + public async Task> PreviewClaimsAsync( + string projectPath, + CancellationToken ct) + { + try + { + var context = await ReadContextAsync(projectPath, ct); + var outlook = proofs.PreviewPublish( + context.LocalIndex, + context.Registry, + context.PluginId, + context.LiveIndex); + var live = proofs.InspectLive(context.Anchor, context.LiveIndex); + var key = keys.TryGet(context.PluginId) + ?? throw new InvalidOperationException($"No signing key is stored for '{context.PluginId}'."); + var preview = new ClaimPublishPreview( + context.PluginId, + key.KeyId, + (live.Generation ?? 0) + 1, + Describe(outlook.Changes), + outlook.DeletionsToken); + return Success("claimPublishPreviewed", preview, + $"Claim publish {preview.PublishNumber} was previewed without signing or journalling anything."); + } + catch (Exception ex) + { + return Failure("claimPublishPreviewFailed", ex.Message); + } + } + + public async Task> SignClaimsAsync( + string projectPath, + string deletionsToken, + bool confirmed, + CancellationToken ct) + { + if (!confirmed) + { + return Failure( + "confirmationRequired", + "Signing claims journals an exact publish and requires --yes after reviewing claims preview."); + } + + try + { + var context = await ReadContextAsync(projectPath, ct); + var live = proofs.InspectLive(context.Anchor, context.LiveIndex); + var prepared = proofs.PreparePublish( + context.LocalIndex, + context.Registry, + context.PluginId, + context.LiveIndex, + allowBootstrap: !live.Signed, + acknowledgeRestoredState: true, + confirmedDeletions: deletionsToken); + return Success( + "claimsSigned", + prepared, + $"Signed and journalled publish {prepared.Pending.Generation}. No bytes were uploaded; use signing head resume to send this exact publish."); + } + catch (Exception ex) + { + return Failure("claimSigningFailed", ex.Message); + } + } + + public WorkflowResult> GetHeadStatus(string pluginId) + { + try + { + var records = heads.RecordsFor(pluginId); + return Success>( + "publisherHeadStatus", + records, + records.Count == 0 + ? $"No publishing history is recorded for '{pluginId}'." + : $"Found {records.Count} publishing-history record(s) for '{pluginId}'."); + } + catch (Exception ex) + { + return Failure>("publisherHeadStatusFailed", ex.Message); + } + } + + public async Task> ConfirmHeadAsync(string projectPath, CancellationToken ct) + { + try + { + var context = await ReadContextAsync(projectPath, ct); + if (context.LiveIndex is null) + throw new InvalidOperationException("There is no published index to confirm."); + proofs.ConfirmPublished(context.Anchor, context.LiveIndex); + return Success("publisherHeadConfirmed", true, + "The live bytes exactly match the pending publish, which is now committed locally."); + } + catch (Exception ex) + { + return Failure("publisherHeadConfirmFailed", ex.Message); + } + } + + public async Task> CommitPendingAsync( + string projectPath, + bool confirmed, + CancellationToken ct) + { + if (!confirmed) + return Failure("confirmationRequired", "Committing a pending publisher head requires --yes."); + + try + { + var context = await ReadContextAsync(projectPath, ct); + var outcome = proofs.ResolvePending(context.Anchor, context.LiveIndex); + if (outcome != IndexProofService.PendingOutcome.Landed) + { + return Failure( + "pendingPublishNotLive", + outcome == IndexProofService.PendingOutcome.NotSent + ? "The pending publish never reached the server. Resume it instead of committing it." + : "The live catalog diverges from both the pending publish and its parent. Nothing was committed."); + } + + proofs.CommitPending(context.Anchor); + return Success("pendingPublishCommitted", true, + "The exact pending publish is live and has been committed locally."); + } + catch (Exception ex) + { + return Failure("pendingPublishCommitFailed", ex.Message); + } + } + + public async Task> ResumeHeadAsync( + string projectPath, + bool confirmed, + CancellationToken ct) + { + if (!confirmed) + return Failure("confirmationRequired", "Resuming an interrupted publish requires --yes."); + + try + { + var context = await ReadContextAsync(projectPath, ct); + var outcome = proofs.ResolvePending(context.Anchor, context.LiveIndex); + if (outcome == IndexProofService.PendingOutcome.Landed) + { + proofs.CommitPending(context.Anchor); + return Success("pendingPublishRecovered", true, + "The interrupted publish had already landed and is now committed locally."); + } + + if (outcome == IndexProofService.PendingOutcome.Diverged) + { + return Failure( + "pendingPublishDiverged", + "The live catalog is neither the pending publish nor its parent. Nothing was uploaded or committed."); + } + + var exactBytes = proofs.ReadPendingIndex(context.Anchor); + var trustContext = ClaimTrustContext.Compute(context.Anchor); + await source.PublishExactIndexAsync( + context.PluginId, + exactBytes, + trustContext, + CancellationToken.None); + var readBack = await source.ReadLiveIndexAsync(context.PluginId, CancellationToken.None); + if (!readBack.Present || readBack.Bytes is null) + throw new InvalidOperationException("The server offered no index after the resumed publish."); + proofs.ConfirmPublished(context.Anchor, readBack.Bytes); + return Success("pendingPublishResumed", true, + "The exact journalled bytes were published, read back, and committed locally."); + } + catch (Exception ex) + { + return Failure("pendingPublishResumeFailed", ex.Message); + } + } + + private SigningKeyStatus Status(ClaimSigningConfig signing) => + new( + signing.PluginId, + signing.KeyId, + signing.PublicKeyFingerprint, + signing.ImportedFromBackup, + heads.RecordsFor(signing.PluginId).Any(record => + record.Committed is not null || record.Pending is not null)) + { + PublicKeyPem = signing.PublicKeyPem + }; + + private async Task ReadContextAsync(string projectPath, CancellationToken ct) + { + var fullProjectPath = Path.GetFullPath(projectPath); + var index = indexFiles.Load(fullProjectPath); + var local = await File.ReadAllBytesAsync(IndexFileService.GetIndexPath(fullProjectPath), ct); + var registry = await source.ReadVerifiedRegistryAsync(index.PluginId, ct); + var resolution = IndexProofService.ResolveAnchor(registry, index.PluginId); + var anchor = resolution.Status switch + { + IndexTrustStatus.Anchored => resolution.Anchor!, + IndexTrustStatus.Unusable => throw new InvalidOperationException( + $"The registry's signing key for '{index.PluginId}' cannot be used: {resolution.Reason}"), + _ => throw new InvalidOperationException( + $"The registry has no signing key recorded for '{index.PluginId}'.") + }; + var remote = await source.ReadLiveIndexAsync(index.PluginId, ct); + if (remote.Present && remote.Bytes is null) + throw new InvalidOperationException("The server reported a live index but returned no bytes."); + return new SigningContext(index.PluginId, local, registry, anchor, remote.Present ? remote.Bytes : null); + } + + private static IReadOnlyList Describe(ClaimSetBuilder.PublishPreview preview) + { + var changes = new List + { + $"{preview.Added} added", + $"{preview.Updated} updated", + $"{preview.Unchanged} unchanged" + }; + changes.AddRange(preview.RemovedReleases.Select(item => $"Permanently withdraw {item.Describe()}")); + changes.AddRange(preview.RemovedGames.Select(item => $"Remove {item.Describe()}")); + changes.AddRange(preview.Narrowed.Select(item => $"Narrow access to {item.Describe()}")); + changes.AddRange(preview.BlockedReleases.Select(item => $"Blocked withdrawn release {item.Describe()}")); + return changes; + } + + private sealed record SigningContext( + string PluginId, + byte[] LocalIndex, + string Registry, + ClaimTrustAnchor Anchor, + byte[]? LiveIndex); + + private static WorkflowResult Success(string status, T value, string message) => + new(status, value, new[] { message }); + + private static WorkflowResult Failure( + string status, + string message, + WorkflowErrorKind kind = WorkflowErrorKind.Conflict) => + new(status, default, new[] { message }, kind); +} diff --git a/src/AccessibilityModManager.Authoring/Workflows/WorkflowResult.cs b/src/AccessibilityModManager.Authoring/Workflows/WorkflowResult.cs new file mode 100644 index 0000000..3c5158b --- /dev/null +++ b/src/AccessibilityModManager.Authoring/Workflows/WorkflowResult.cs @@ -0,0 +1,83 @@ +namespace AccessibilityModManager.Authoring.Workflows; + +public enum WorkflowErrorKind +{ + None, + Usage, + Validation, + Authentication, + Conflict, + Cancelled +} + +public sealed record WorkflowResult +{ + public WorkflowResult( + string status, + T? value, + IReadOnlyList messages, + WorkflowErrorKind errorKind = WorkflowErrorKind.None, + IReadOnlyList? completedPhases = null) + { + Status = string.IsNullOrWhiteSpace(status) + ? throw new ArgumentException("Status is required.", nameof(status)) + : status; + Value = value; + Messages = messages ?? throw new ArgumentNullException(nameof(messages)); + ErrorKind = errorKind; + CompletedPhases = completedPhases; + } + + public string Status { get; } + public T? Value { get; } + public IReadOnlyList Messages { get; } + public WorkflowErrorKind ErrorKind { get; } + public IReadOnlyList? CompletedPhases { get; } +} + +public sealed class WorkflowException : Exception +{ + public WorkflowException( + WorkflowErrorKind errorKind, + string status, + IReadOnlyList messages, + IReadOnlyList? completedPhases = null, + Exception? innerException = null) + : base(CreateMessage(status, messages), innerException) + { + ErrorKind = errorKind; + Status = string.IsNullOrWhiteSpace(status) + ? throw new ArgumentException("Status is required.", nameof(status)) + : status; + Messages = messages ?? throw new ArgumentNullException(nameof(messages)); + CompletedPhases = completedPhases; + } + + public WorkflowErrorKind ErrorKind { get; } + public string Status { get; } + public IReadOnlyList Messages { get; } + public IReadOnlyList? CompletedPhases { get; } + + public WorkflowResult ToResult(T? value = default, bool verbose = false) + { + if (!verbose) + { + return new WorkflowResult(Status, value, Messages, ErrorKind, CompletedPhases); + } + + var detailedMessages = new List(Messages.Count + 1); + detailedMessages.AddRange(Messages); + detailedMessages.Add(ToString()); + return new WorkflowResult(Status, value, detailedMessages, ErrorKind, CompletedPhases); + } + + private static string CreateMessage(string status, IReadOnlyList messages) + { + if (messages is { Count: > 0 } && !string.IsNullOrWhiteSpace(messages[0])) + { + return messages[0]; + } + + return status; + } +} diff --git a/tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj b/tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj index 45d1687..7db5bc6 100644 --- a/tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj +++ b/tests/AccessibilityModManager.Tests/AccessibilityModManager.Tests.csproj @@ -7,6 +7,10 @@ false + + $(DefineConstants);REGISTRY_ADMIN + + @@ -16,6 +20,8 @@ + +