1151 lines
35 KiB
Markdown
1151 lines
35 KiB
Markdown
# Gitea Release Flow + Windows Service 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:** Ship a tag-triggered Gitea release flow for ClaudeMailbox and make the binary self-install as a Windows Service via `install-service` / `uninstall-service` verbs seeded from `%ProgramData%\ClaudeMailbox\mailbox.json`.
|
||
|
||
**Architecture:** Config precedence (CLI flag > JSON file > default) feeds the existing `DaemonConfig`. `UseWindowsService()` is a no-op in console mode and enables SCM lifetime when the service starts. Service install shells out to `sc.exe` with admin + Windows guards. Release workflow mirrors the ClaudeDo pattern (ubuntu-latest, `dotnet publish -r win-x64 --self-contained`, MinVer override, Gitea REST API).
|
||
|
||
**Tech Stack:** .NET 8, ASP.NET Core, xUnit, `Microsoft.Extensions.Hosting.WindowsServices`, MinVer (already present), Gitea Actions, `sc.exe`.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-24-gitea-release-and-windows-service-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| Path | Action | Responsibility |
|
||
|---|---|---|
|
||
| `src/ClaudeMailbox/Config/FileConfig.cs` | create | JSON model + loader for `mailbox.json` |
|
||
| `src/ClaudeMailbox/Config/ConfigResolver.cs` | create | Pure helper: merge CLI args + FileConfig → DaemonConfig |
|
||
| `src/ClaudeMailbox/Cli/ServiceCommands.cs` | create | `install-service` / `uninstall-service` / `start` / `stop` / `status` verbs |
|
||
| `src/ClaudeMailbox/Program.cs` | modify | Dispatch new verbs, apply config precedence |
|
||
| `src/ClaudeMailbox/ServerHost.cs` | modify | `UseWindowsService()` call |
|
||
| `src/ClaudeMailbox/ClaudeMailbox.csproj` | modify | Add `Microsoft.Extensions.Hosting.WindowsServices` |
|
||
| `tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs` | create | Unit tests for JSON loader |
|
||
| `tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs` | create | Unit tests for precedence logic |
|
||
| `.gitea/workflows/ci.yml` | create | Build + test on main |
|
||
| `.gitea/workflows/release.yml` | create | Tag-triggered publish to Gitea release |
|
||
| `README.md` | modify | Document service verbs, config file, manual smoke test |
|
||
|
||
---
|
||
|
||
## Task 1: FileConfig model + loader
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeMailbox/Config/FileConfig.cs`
|
||
- Test: `tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs`:
|
||
|
||
```csharp
|
||
using ClaudeMailbox.Config;
|
||
|
||
namespace ClaudeMailbox.Tests.Config;
|
||
|
||
public sealed class FileConfigTests
|
||
{
|
||
[Fact]
|
||
public void Load_ReturnsEmpty_WhenPathIsNullAndDefaultMissing()
|
||
{
|
||
var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json");
|
||
var cfg = FileConfig.Load(explicitPath: null, defaultPath: missing);
|
||
|
||
Assert.Null(cfg.Port);
|
||
Assert.Null(cfg.Bind);
|
||
Assert.Null(cfg.DbPath);
|
||
}
|
||
|
||
[Fact]
|
||
public void Load_ReadsDefaultPath_WhenExplicitPathNull()
|
||
{
|
||
var path = WriteTemp("""{"port":9000,"bind":"0.0.0.0","dbPath":"C:\\tmp\\a.db"}""");
|
||
try
|
||
{
|
||
var cfg = FileConfig.Load(explicitPath: null, defaultPath: path);
|
||
Assert.Equal(9000, cfg.Port);
|
||
Assert.Equal("0.0.0.0", cfg.Bind);
|
||
Assert.Equal(@"C:\tmp\a.db", cfg.DbPath);
|
||
}
|
||
finally { File.Delete(path); }
|
||
}
|
||
|
||
[Fact]
|
||
public void Load_ExplicitPath_WinsOverDefault()
|
||
{
|
||
var defaultPath = WriteTemp("""{"port":1111}""");
|
||
var explicitPath = WriteTemp("""{"port":2222}""");
|
||
try
|
||
{
|
||
var cfg = FileConfig.Load(explicitPath: explicitPath, defaultPath: defaultPath);
|
||
Assert.Equal(2222, cfg.Port);
|
||
}
|
||
finally { File.Delete(defaultPath); File.Delete(explicitPath); }
|
||
}
|
||
|
||
[Fact]
|
||
public void Load_ExplicitPathMissing_Throws()
|
||
{
|
||
var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json");
|
||
var ex = Assert.Throws<FileNotFoundException>(() =>
|
||
FileConfig.Load(explicitPath: missing, defaultPath: null));
|
||
Assert.Contains(missing, ex.Message);
|
||
}
|
||
|
||
[Fact]
|
||
public void Load_MissingFields_AreNull()
|
||
{
|
||
var path = WriteTemp("""{"port":1234}""");
|
||
try
|
||
{
|
||
var cfg = FileConfig.Load(explicitPath: path, defaultPath: null);
|
||
Assert.Equal(1234, cfg.Port);
|
||
Assert.Null(cfg.Bind);
|
||
Assert.Null(cfg.DbPath);
|
||
}
|
||
finally { File.Delete(path); }
|
||
}
|
||
|
||
[Fact]
|
||
public void Load_CaseInsensitive_PropertyNames()
|
||
{
|
||
var path = WriteTemp("""{"Port":1,"BIND":"x","DBPATH":"y"}""");
|
||
try
|
||
{
|
||
var cfg = FileConfig.Load(explicitPath: path, defaultPath: null);
|
||
Assert.Equal(1, cfg.Port);
|
||
Assert.Equal("x", cfg.Bind);
|
||
Assert.Equal("y", cfg.DbPath);
|
||
}
|
||
finally { File.Delete(path); }
|
||
}
|
||
|
||
[Fact]
|
||
public void Load_MalformedJson_Throws()
|
||
{
|
||
var path = WriteTemp("not json");
|
||
try
|
||
{
|
||
Assert.ThrowsAny<Exception>(() => FileConfig.Load(explicitPath: path, defaultPath: null));
|
||
}
|
||
finally { File.Delete(path); }
|
||
}
|
||
|
||
private static string WriteTemp(string content)
|
||
{
|
||
var p = Path.Combine(Path.GetTempPath(), $"mailbox-{Guid.NewGuid():N}.json");
|
||
File.WriteAllText(p, content);
|
||
return p;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj --filter "FullyQualifiedName~FileConfigTests"`
|
||
Expected: compile error — `FileConfig` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `FileConfig`**
|
||
|
||
Create `src/ClaudeMailbox/Config/FileConfig.cs`:
|
||
|
||
```csharp
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
|
||
namespace ClaudeMailbox.Config;
|
||
|
||
public sealed class FileConfig
|
||
{
|
||
[JsonPropertyName("port")]
|
||
public int? Port { get; set; }
|
||
|
||
[JsonPropertyName("bind")]
|
||
public string? Bind { get; set; }
|
||
|
||
[JsonPropertyName("dbPath")]
|
||
public string? DbPath { get; set; }
|
||
|
||
private static readonly JsonSerializerOptions Options = new()
|
||
{
|
||
PropertyNameCaseInsensitive = true,
|
||
};
|
||
|
||
public static FileConfig Load(string? explicitPath, string? defaultPath)
|
||
{
|
||
if (!string.IsNullOrEmpty(explicitPath))
|
||
{
|
||
if (!File.Exists(explicitPath))
|
||
throw new FileNotFoundException($"Config file not found: {explicitPath}", explicitPath);
|
||
return Parse(File.ReadAllText(explicitPath));
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(defaultPath) && File.Exists(defaultPath))
|
||
return Parse(File.ReadAllText(defaultPath));
|
||
|
||
return new FileConfig();
|
||
}
|
||
|
||
private static FileConfig Parse(string json)
|
||
{
|
||
return JsonSerializer.Deserialize<FileConfig>(json, Options) ?? new FileConfig();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to confirm pass**
|
||
|
||
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj --filter "FullyQualifiedName~FileConfigTests"`
|
||
Expected: 7 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeMailbox/Config/FileConfig.cs tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs
|
||
git commit -m "feat(config): add FileConfig model and JSON loader"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: ConfigResolver (CLI > File > Default precedence)
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeMailbox/Config/ConfigResolver.cs`
|
||
- Test: `tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs`:
|
||
|
||
```csharp
|
||
using ClaudeMailbox.Config;
|
||
|
||
namespace ClaudeMailbox.Tests.Config;
|
||
|
||
public sealed class ConfigResolverTests
|
||
{
|
||
[Fact]
|
||
public void CliFlag_WinsOverFile()
|
||
{
|
||
var file = new FileConfig { Port = 1000 };
|
||
var cfg = ConfigResolver.Build(new[] { "--port", "9999" }, file);
|
||
Assert.Equal(9999, cfg.Port);
|
||
}
|
||
|
||
[Fact]
|
||
public void File_WinsOverDefault()
|
||
{
|
||
var file = new FileConfig { Port = 1000, Bind = "0.0.0.0", DbPath = "/tmp/x.db" };
|
||
var cfg = ConfigResolver.Build(Array.Empty<string>(), file);
|
||
Assert.Equal(1000, cfg.Port);
|
||
Assert.Equal("0.0.0.0", cfg.BindAddress);
|
||
Assert.Equal("/tmp/x.db", cfg.DbPath);
|
||
}
|
||
|
||
[Fact]
|
||
public void Default_UsedWhenNeitherCliNorFile()
|
||
{
|
||
var cfg = ConfigResolver.Build(Array.Empty<string>(), new FileConfig());
|
||
Assert.Equal(DaemonConfig.DefaultPort, cfg.Port);
|
||
Assert.Equal(DaemonConfig.DefaultBindAddress, cfg.BindAddress);
|
||
Assert.Equal(Paths.DefaultDbPath(), cfg.DbPath);
|
||
}
|
||
|
||
[Fact]
|
||
public void Mixed_CliPort_FileDbPath_DefaultBind()
|
||
{
|
||
var file = new FileConfig { DbPath = "/tmp/mixed.db" };
|
||
var cfg = ConfigResolver.Build(new[] { "--port", "7000" }, file);
|
||
Assert.Equal(7000, cfg.Port);
|
||
Assert.Equal(DaemonConfig.DefaultBindAddress, cfg.BindAddress);
|
||
Assert.Equal("/tmp/mixed.db", cfg.DbPath);
|
||
}
|
||
|
||
[Fact]
|
||
public void CliDbPath_ExpandsEnvVars()
|
||
{
|
||
var file = new FileConfig();
|
||
var cfg = ConfigResolver.Build(new[] { "--db-path", "~/foo.db" }, file);
|
||
Assert.DoesNotContain("~", cfg.DbPath);
|
||
}
|
||
|
||
[Fact]
|
||
public void InvalidPortFlag_FallsBackToFileOrDefault()
|
||
{
|
||
var file = new FileConfig { Port = 4242 };
|
||
var cfg = ConfigResolver.Build(new[] { "--port", "not-a-number" }, file);
|
||
Assert.Equal(4242, cfg.Port);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, confirm they fail**
|
||
|
||
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj --filter "FullyQualifiedName~ConfigResolverTests"`
|
||
Expected: compile error — `ConfigResolver` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `ConfigResolver`**
|
||
|
||
Create `src/ClaudeMailbox/Config/ConfigResolver.cs`:
|
||
|
||
```csharp
|
||
using ClaudeMailbox.Cli;
|
||
|
||
namespace ClaudeMailbox.Config;
|
||
|
||
public static class ConfigResolver
|
||
{
|
||
public static DaemonConfig Build(string[] serveArgs, FileConfig file)
|
||
{
|
||
var cliPort = ParseIntOption(serveArgs, "--port");
|
||
var cliBind = ClientCommands.GetOption(serveArgs, "--bind");
|
||
var cliDbPath = ClientCommands.GetOption(serveArgs, "--db-path");
|
||
|
||
var port = cliPort ?? file.Port ?? DaemonConfig.DefaultPort;
|
||
var bind = cliBind ?? file.Bind ?? DaemonConfig.DefaultBindAddress;
|
||
var dbPathRaw = cliDbPath ?? file.DbPath ?? Paths.DefaultDbPath();
|
||
|
||
return new DaemonConfig
|
||
{
|
||
Port = port,
|
||
BindAddress = bind,
|
||
DbPath = Paths.Expand(dbPathRaw),
|
||
};
|
||
}
|
||
|
||
private static int? ParseIntOption(string[] args, string name)
|
||
{
|
||
var raw = ClientCommands.GetOption(args, name);
|
||
return int.TryParse(raw, out var v) ? v : null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, confirm pass**
|
||
|
||
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj --filter "FullyQualifiedName~ConfigResolverTests"`
|
||
Expected: 6 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeMailbox/Config/ConfigResolver.cs tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs
|
||
git commit -m "feat(config): add ConfigResolver with CLI>file>default precedence"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Wire FileConfig + ConfigResolver into Program.cs
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeMailbox/Program.cs`
|
||
|
||
- [ ] **Step 1: Replace config construction in `Program.cs`**
|
||
|
||
Full new content of `src/ClaudeMailbox/Program.cs`:
|
||
|
||
```csharp
|
||
using ClaudeMailbox;
|
||
using ClaudeMailbox.Cli;
|
||
using ClaudeMailbox.Config;
|
||
|
||
if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
|
||
{
|
||
return await ClientCommands.RunAsync(args);
|
||
}
|
||
|
||
var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args;
|
||
|
||
var explicitConfig = ClientCommands.GetOption(serveArgs, "--config");
|
||
var defaultConfig = Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||
"ClaudeMailbox", "mailbox.json");
|
||
|
||
var fileConfig = FileConfig.Load(explicitConfig, defaultConfig);
|
||
var cfg = ConfigResolver.Build(serveArgs, fileConfig);
|
||
|
||
var builder = ServerHost.CreateBuilder(cfg, serveArgs);
|
||
builder.WebHost.UseUrls(cfg.BaseUrl);
|
||
|
||
var app = builder.Build();
|
||
ServerHost.ConfigurePipeline(app);
|
||
|
||
app.Logger.LogInformation("ClaudeMailbox listening on {Url} (db: {Db})", cfg.BaseUrl, cfg.DbPath);
|
||
|
||
try
|
||
{
|
||
await app.RunAsync();
|
||
return 0;
|
||
}
|
||
catch (IOException ex) when (ex.Message.Contains("address already in use", StringComparison.OrdinalIgnoreCase)
|
||
|| ex.Message.Contains("Only one usage", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
Console.Error.WriteLine($"Port {cfg.Port} is already in use. Another claude-mailbox instance may be running.");
|
||
return 3;
|
||
}
|
||
|
||
public partial class Program { }
|
||
```
|
||
|
||
Note: removed the local `ParseInt` helper (now in `ConfigResolver`).
|
||
|
||
- [ ] **Step 2: Run full test suite**
|
||
|
||
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj`
|
||
Expected: all existing tests still pass. No new failures.
|
||
|
||
- [ ] **Step 3: Smoke-test console mode**
|
||
|
||
Run: `dotnet run --project src/ClaudeMailbox -- serve --port 47999`
|
||
Expected: `ClaudeMailbox listening on http://127.0.0.1:47999` in log output. Ctrl+C to stop.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeMailbox/Program.cs
|
||
git commit -m "feat(config): load mailbox.json with CLI override in Program"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Add WindowsServices package + UseWindowsService
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeMailbox/ClaudeMailbox.csproj`
|
||
- Modify: `src/ClaudeMailbox/ServerHost.cs`
|
||
|
||
- [ ] **Step 1: Add package reference**
|
||
|
||
In `src/ClaudeMailbox/ClaudeMailbox.csproj`, add to the `<ItemGroup>` with package references:
|
||
|
||
```xml
|
||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||
```
|
||
|
||
- [ ] **Step 2: Wire `UseWindowsService()` in `ServerHost.CreateBuilder`**
|
||
|
||
In `src/ClaudeMailbox/ServerHost.cs`, in `CreateBuilder`, after `var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());` add:
|
||
|
||
```csharp
|
||
builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");
|
||
```
|
||
|
||
Add the using at the top:
|
||
|
||
```csharp
|
||
using Microsoft.Extensions.Hosting.WindowsServices;
|
||
```
|
||
|
||
- [ ] **Step 3: Build and run existing tests**
|
||
|
||
Run: `dotnet build src/ClaudeMailbox/ClaudeMailbox.csproj`
|
||
Expected: success, no warnings about missing package.
|
||
|
||
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj`
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeMailbox/ClaudeMailbox.csproj src/ClaudeMailbox/ServerHost.cs
|
||
git commit -m "feat(service): enable Windows Service hosting lifetime"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: ServiceCommands skeleton (platform + admin gates)
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeMailbox/Cli/ServiceCommands.cs`
|
||
|
||
This task adds the module with platform/admin guards but no verb bodies yet (verbs added in Tasks 6–7). No dispatch wired yet — that's Task 8. This lets us build incrementally and verify the gates work on Linux CI.
|
||
|
||
- [ ] **Step 1: Create the module**
|
||
|
||
Create `src/ClaudeMailbox/Cli/ServiceCommands.cs`:
|
||
|
||
```csharp
|
||
using System.Diagnostics;
|
||
using System.Runtime.Versioning;
|
||
using System.Security.Principal;
|
||
|
||
namespace ClaudeMailbox.Cli;
|
||
|
||
public static class ServiceCommands
|
||
{
|
||
public const string ServiceName = "ClaudeMailbox";
|
||
|
||
public static Task<int> RunAsync(string[] args)
|
||
{
|
||
if (!OperatingSystem.IsWindows())
|
||
{
|
||
Console.Error.WriteLine("Service commands are Windows-only.");
|
||
return Task.FromResult(2);
|
||
}
|
||
|
||
var verb = args[0];
|
||
return verb switch
|
||
{
|
||
"install-service" => Task.FromResult(InstallService(args)),
|
||
"uninstall-service" => Task.FromResult(UninstallService(args)),
|
||
"start" => Task.FromResult(RunSc("start", ServiceName)),
|
||
"stop" => Task.FromResult(RunSc("stop", ServiceName)),
|
||
"status" => Task.FromResult(Status()),
|
||
_ => Task.FromResult(PrintError($"Unknown service command: {verb}")),
|
||
};
|
||
}
|
||
|
||
[SupportedOSPlatform("windows")]
|
||
private static bool IsAdministrator()
|
||
{
|
||
using var identity = WindowsIdentity.GetCurrent();
|
||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||
}
|
||
|
||
[SupportedOSPlatform("windows")]
|
||
private static int RequireAdmin()
|
||
{
|
||
if (IsAdministrator()) return 0;
|
||
Console.Error.WriteLine("This command requires Administrator privileges.");
|
||
return 5;
|
||
}
|
||
|
||
[SupportedOSPlatform("windows")]
|
||
private static int InstallService(string[] args)
|
||
{
|
||
var admin = RequireAdmin();
|
||
if (admin != 0) return admin;
|
||
|
||
Console.Error.WriteLine("install-service: not yet implemented.");
|
||
return 1;
|
||
}
|
||
|
||
[SupportedOSPlatform("windows")]
|
||
private static int UninstallService(string[] args)
|
||
{
|
||
var admin = RequireAdmin();
|
||
if (admin != 0) return admin;
|
||
|
||
Console.Error.WriteLine("uninstall-service: not yet implemented.");
|
||
return 1;
|
||
}
|
||
|
||
[SupportedOSPlatform("windows")]
|
||
private static int Status()
|
||
{
|
||
Console.Error.WriteLine("status: not yet implemented.");
|
||
return 1;
|
||
}
|
||
|
||
[SupportedOSPlatform("windows")]
|
||
internal static int RunSc(params string[] scArgs)
|
||
{
|
||
var psi = new ProcessStartInfo("sc.exe")
|
||
{
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
};
|
||
foreach (var a in scArgs) psi.ArgumentList.Add(a);
|
||
|
||
using var proc = Process.Start(psi)!;
|
||
var stdout = proc.StandardOutput.ReadToEnd();
|
||
var stderr = proc.StandardError.ReadToEnd();
|
||
proc.WaitForExit();
|
||
|
||
if (!string.IsNullOrWhiteSpace(stdout)) Console.Write(stdout);
|
||
if (!string.IsNullOrWhiteSpace(stderr)) Console.Error.Write(stderr);
|
||
return proc.ExitCode;
|
||
}
|
||
|
||
private static int PrintError(string msg)
|
||
{
|
||
Console.Error.WriteLine(msg);
|
||
return 1;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build and test**
|
||
|
||
Run: `dotnet build src/ClaudeMailbox/ClaudeMailbox.csproj`
|
||
Expected: success on both Linux and Windows (the `SupportedOSPlatform` attribute plus `OperatingSystem.IsWindows()` short-circuit keeps the analyzer happy).
|
||
|
||
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj`
|
||
Expected: existing tests pass.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeMailbox/Cli/ServiceCommands.cs
|
||
git commit -m "feat(service): add ServiceCommands skeleton with platform/admin gates"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: install-service verb
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeMailbox/Cli/ServiceCommands.cs`
|
||
|
||
- [ ] **Step 1: Replace `InstallService` body**
|
||
|
||
In `src/ClaudeMailbox/Cli/ServiceCommands.cs`, replace the `InstallService` method with:
|
||
|
||
```csharp
|
||
[SupportedOSPlatform("windows")]
|
||
private static int InstallService(string[] args)
|
||
{
|
||
var admin = RequireAdmin();
|
||
if (admin != 0) return admin;
|
||
|
||
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||
var dataDir = Path.Combine(programData, "ClaudeMailbox");
|
||
var configPath = Path.Combine(dataDir, "mailbox.json");
|
||
var defaultDbPath = Path.Combine(dataDir, "mailbox.db");
|
||
|
||
Directory.CreateDirectory(dataDir);
|
||
ApplyLocalServiceAcl(dataDir);
|
||
|
||
if (!File.Exists(configPath))
|
||
{
|
||
var port = ClientCommands.GetOption(args, "--port") ?? "47822";
|
||
var bind = ClientCommands.GetOption(args, "--bind") ?? "127.0.0.1";
|
||
var dbPath = ClientCommands.GetOption(args, "--db-path") ?? defaultDbPath;
|
||
|
||
var json = $$"""
|
||
{
|
||
"port": {{port}},
|
||
"bind": "{{bind}}",
|
||
"dbPath": {{System.Text.Json.JsonSerializer.Serialize(dbPath)}}
|
||
}
|
||
""";
|
||
File.WriteAllText(configPath, json);
|
||
Console.WriteLine($"Seeded config: {configPath}");
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine($"Config already exists, leaving untouched: {configPath}");
|
||
}
|
||
|
||
var exe = Environment.ProcessPath
|
||
?? throw new InvalidOperationException("Cannot resolve current executable path.");
|
||
|
||
var binPath = $"\"{exe}\" serve --config \"{configPath}\"";
|
||
|
||
var createExit = RunSc("create", ServiceName,
|
||
"binPath=", binPath,
|
||
"start=", "auto",
|
||
"DisplayName=", "Claude Mailbox",
|
||
"obj=", "NT AUTHORITY\\LocalService");
|
||
if (createExit != 0)
|
||
{
|
||
Console.Error.WriteLine($"sc create failed (exit {createExit}).");
|
||
return createExit;
|
||
}
|
||
|
||
RunSc("description", ServiceName, "MCP mailbox server for parallel Claude sessions");
|
||
|
||
Console.WriteLine($"Service '{ServiceName}' installed. Start with: claude-mailbox start");
|
||
return 0;
|
||
}
|
||
|
||
[SupportedOSPlatform("windows")]
|
||
private static void ApplyLocalServiceAcl(string path)
|
||
{
|
||
var info = new DirectoryInfo(path);
|
||
var security = info.GetAccessControl();
|
||
var localService = new SecurityIdentifier(WellKnownSidType.LocalServiceSid, null);
|
||
security.AddAccessRule(new FileSystemAccessRule(
|
||
localService,
|
||
FileSystemRights.Modify | FileSystemRights.Synchronize,
|
||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
|
||
PropagationFlags.None,
|
||
AccessControlType.Allow));
|
||
info.SetAccessControl(security);
|
||
}
|
||
```
|
||
|
||
Add these using directives at the top of the file (with the existing usings):
|
||
|
||
```csharp
|
||
using System.Security.AccessControl;
|
||
```
|
||
|
||
- [ ] **Step 2: Build on Linux (CI runner will do this too)**
|
||
|
||
Run: `dotnet build src/ClaudeMailbox/ClaudeMailbox.csproj`
|
||
Expected: success. `SupportedOSPlatform("windows")` suppresses CA1416 warnings for the ACL + SID types.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeMailbox/Cli/ServiceCommands.cs
|
||
git commit -m "feat(service): implement install-service verb"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: uninstall-service + status verbs
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeMailbox/Cli/ServiceCommands.cs`
|
||
|
||
- [ ] **Step 1: Replace `UninstallService` and `Status` bodies**
|
||
|
||
In `src/ClaudeMailbox/Cli/ServiceCommands.cs`, replace both methods:
|
||
|
||
```csharp
|
||
[SupportedOSPlatform("windows")]
|
||
private static int UninstallService(string[] args)
|
||
{
|
||
var admin = RequireAdmin();
|
||
if (admin != 0) return admin;
|
||
|
||
var purge = Array.IndexOf(args, "--purge") >= 0;
|
||
|
||
// Best-effort stop; ignore failure if not running.
|
||
RunSc("stop", ServiceName);
|
||
|
||
var deleteExit = RunSc("delete", ServiceName);
|
||
if (deleteExit != 0)
|
||
{
|
||
Console.Error.WriteLine($"sc delete failed (exit {deleteExit}).");
|
||
return deleteExit;
|
||
}
|
||
|
||
if (purge)
|
||
{
|
||
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||
var dataDir = Path.Combine(programData, "ClaudeMailbox");
|
||
if (Directory.Exists(dataDir))
|
||
{
|
||
Directory.Delete(dataDir, recursive: true);
|
||
Console.WriteLine($"Purged: {dataDir}");
|
||
}
|
||
}
|
||
|
||
Console.WriteLine($"Service '{ServiceName}' uninstalled.");
|
||
return 0;
|
||
}
|
||
|
||
[SupportedOSPlatform("windows")]
|
||
private static int Status()
|
||
{
|
||
var psi = new ProcessStartInfo("sc.exe")
|
||
{
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
};
|
||
psi.ArgumentList.Add("query");
|
||
psi.ArgumentList.Add(ServiceName);
|
||
|
||
using var proc = Process.Start(psi)!;
|
||
var stdout = proc.StandardOutput.ReadToEnd();
|
||
proc.WaitForExit();
|
||
|
||
if (proc.ExitCode != 0)
|
||
{
|
||
Console.WriteLine("NotInstalled");
|
||
return 2;
|
||
}
|
||
|
||
var state = stdout.Split('\n')
|
||
.Select(l => l.Trim())
|
||
.FirstOrDefault(l => l.StartsWith("STATE", StringComparison.Ordinal))
|
||
?? "";
|
||
|
||
if (state.Contains("RUNNING", StringComparison.Ordinal))
|
||
{
|
||
Console.WriteLine("Running");
|
||
return 0;
|
||
}
|
||
|
||
Console.WriteLine("Stopped");
|
||
return 1;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build**
|
||
|
||
Run: `dotnet build src/ClaudeMailbox/ClaudeMailbox.csproj`
|
||
Expected: success.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeMailbox/Cli/ServiceCommands.cs
|
||
git commit -m "feat(service): implement uninstall-service and status verbs"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Dispatch service verbs in Program.cs
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeMailbox/Program.cs`
|
||
|
||
- [ ] **Step 1: Extend the early dispatch**
|
||
|
||
In `src/ClaudeMailbox/Program.cs`, replace the first dispatch block:
|
||
|
||
```csharp
|
||
if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
|
||
{
|
||
return await ClientCommands.RunAsync(args);
|
||
}
|
||
```
|
||
|
||
with:
|
||
|
||
```csharp
|
||
if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
|
||
{
|
||
return await ClientCommands.RunAsync(args);
|
||
}
|
||
|
||
if (args.Length > 0 && args[0] is "install-service" or "uninstall-service" or "start" or "stop" or "status")
|
||
{
|
||
return await ServiceCommands.RunAsync(args);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build and run tests**
|
||
|
||
Run: `dotnet build src/ClaudeMailbox/ClaudeMailbox.csproj`
|
||
Expected: success.
|
||
|
||
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj`
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 3: Smoke test on Linux (should refuse cleanly)**
|
||
|
||
Run: `dotnet run --project src/ClaudeMailbox -- status`
|
||
Expected: exits with code 2, stderr `Service commands are Windows-only.`
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeMailbox/Program.cs
|
||
git commit -m "feat(service): dispatch service verbs from Program entrypoint"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: CI workflow (build + test on main)
|
||
|
||
**Files:**
|
||
- Create: `.gitea/workflows/ci.yml`
|
||
|
||
- [ ] **Step 1: Write workflow file**
|
||
|
||
Create `.gitea/workflows/ci.yml`:
|
||
|
||
```yaml
|
||
name: CI
|
||
|
||
on:
|
||
push:
|
||
branches:
|
||
- main
|
||
pull_request:
|
||
branches:
|
||
- main
|
||
|
||
jobs:
|
||
build:
|
||
runs-on: ubuntu-latest
|
||
env:
|
||
DOTNET_ROOT: /home/mika/.dotnet
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v4
|
||
with:
|
||
fetch-depth: 0
|
||
|
||
- name: Build
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$DOTNET_ROOT:$PATH"
|
||
dotnet build ClaudeMailbox.slnx -c Release
|
||
|
||
- name: Test
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$DOTNET_ROOT:$PATH"
|
||
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj \
|
||
-c Release --no-build --logger "console;verbosity=normal"
|
||
```
|
||
|
||
Note: `fetch-depth: 0` is required because MinVer walks the commit history to compute versions from tags.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add .gitea/workflows/ci.yml
|
||
git commit -m "ci: add build+test workflow for main and PRs"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Release workflow (tag-triggered publish)
|
||
|
||
**Files:**
|
||
- Create: `.gitea/workflows/release.yml`
|
||
|
||
- [ ] **Step 1: Write workflow file**
|
||
|
||
Create `.gitea/workflows/release.yml`:
|
||
|
||
```yaml
|
||
name: Release
|
||
|
||
on:
|
||
push:
|
||
tags:
|
||
- 'v*'
|
||
|
||
jobs:
|
||
release:
|
||
runs-on: ubuntu-latest
|
||
env:
|
||
DOTNET_ROOT: /home/mika/.dotnet
|
||
GITEA_API: https://git.kuns.dev/api/v1
|
||
REPO: releases/ClaudeMailbox
|
||
steps:
|
||
- name: Resolve version
|
||
id: ver
|
||
run: |
|
||
set -euo pipefail
|
||
TAG="${{ github.ref_name }}"
|
||
VERSION="${TAG#v}"
|
||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||
echo "Building version: $VERSION (tag: $TAG)"
|
||
|
||
- name: Prepare workspace
|
||
id: ws
|
||
run: |
|
||
set -euo pipefail
|
||
WORK="$(mktemp -d -t claude-mailbox-release-XXXXXX)"
|
||
echo "dir=$WORK" >> "$GITHUB_OUTPUT"
|
||
echo "Workspace: $WORK"
|
||
|
||
- name: Checkout tag
|
||
env:
|
||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||
WORK: ${{ steps.ws.outputs.dir }}
|
||
TAG: ${{ steps.ver.outputs.tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
git clone --branch "$TAG" \
|
||
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
|
||
"$WORK/src"
|
||
git -C "$WORK/src" log -1 --oneline
|
||
|
||
- name: Publish (win-x64, self-contained, single-file)
|
||
env:
|
||
WORK: ${{ steps.ws.outputs.dir }}
|
||
VERSION: ${{ steps.ver.outputs.version }}
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$DOTNET_ROOT:$PATH"
|
||
cd "$WORK/src"
|
||
dotnet publish src/ClaudeMailbox/ClaudeMailbox.csproj \
|
||
-c Release -r win-x64 --self-contained true \
|
||
/p:MinVerVersionOverride=$VERSION \
|
||
/p:PublishSingleFile=true \
|
||
/p:IncludeNativeLibrariesForSelfExtract=true \
|
||
-o out/app
|
||
|
||
- name: Package assets
|
||
env:
|
||
WORK: ${{ steps.ws.outputs.dir }}
|
||
VERSION: ${{ steps.ver.outputs.version }}
|
||
run: |
|
||
set -euo pipefail
|
||
cd "$WORK/src"
|
||
mkdir -p assets
|
||
|
||
EXE_SRC=$(ls out/app/*.exe | head -n 1)
|
||
if [ -z "$EXE_SRC" ]; then
|
||
echo "::error::No .exe produced by publish" >&2
|
||
exit 1
|
||
fi
|
||
|
||
EXE_NAME="claude-mailbox-${VERSION}-win-x64.exe"
|
||
cp "$EXE_SRC" "assets/${EXE_NAME}"
|
||
|
||
( cd assets && sha256sum "${EXE_NAME}" > checksums.txt )
|
||
|
||
echo "--- assets ---"
|
||
ls -la assets
|
||
|
||
- name: Create Gitea Release
|
||
id: release
|
||
env:
|
||
TAG: ${{ steps.ver.outputs.tag }}
|
||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
BODY=$(jq -n \
|
||
--arg tag "$TAG" \
|
||
--arg name "$TAG" \
|
||
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
|
||
RESP=$(curl -sS -X POST \
|
||
-H "Authorization: token ${TOKEN}" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$BODY" \
|
||
"${GITEA_API}/repos/${REPO}/releases")
|
||
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
|
||
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||
echo "::error::Release creation failed" >&2
|
||
echo "$RESP" >&2
|
||
exit 1
|
||
fi
|
||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||
echo "Created release id=$RELEASE_ID for tag=$TAG"
|
||
|
||
- name: Upload release assets
|
||
env:
|
||
WORK: ${{ steps.ws.outputs.dir }}
|
||
VERSION: ${{ steps.ver.outputs.version }}
|
||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
cd "$WORK/src/assets"
|
||
for f in \
|
||
"claude-mailbox-${VERSION}-win-x64.exe" \
|
||
"checksums.txt"
|
||
do
|
||
echo "Uploading: $f"
|
||
curl -sS --fail-with-body -X POST \
|
||
-H "Authorization: token ${TOKEN}" \
|
||
-F "attachment=@${f}" \
|
||
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \
|
||
> /dev/null
|
||
done
|
||
echo "All assets uploaded."
|
||
|
||
- name: Cleanup workspace
|
||
if: always()
|
||
env:
|
||
WORK: ${{ steps.ws.outputs.dir }}
|
||
run: |
|
||
rm -rf "$WORK" || true
|
||
```
|
||
|
||
**Assumptions to verify before first tag push** (from the spec's Open Questions):
|
||
- `REPO` slug is `releases/ClaudeMailbox` — if the actual Gitea slug differs, edit this file and `ci.yml`.
|
||
- `secrets.GITEA_TOKEN` exists in the Gitea repo settings with `write:repository` scope.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add .gitea/workflows/release.yml
|
||
git commit -m "ci: add tag-triggered Gitea release workflow"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: README updates
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
|
||
- [ ] **Step 1: Update the "Daemon lifecycle" and add service subsections**
|
||
|
||
Replace the `## Daemon lifecycle` section in `README.md` with:
|
||
|
||
```markdown
|
||
## Daemon lifecycle
|
||
|
||
Pick whichever level of automation you want:
|
||
|
||
1. **Manual.** `claude-mailbox serve` in a terminal.
|
||
2. **Startup shortcut.** Shortcut to `claude-mailbox serve` in `shell:startup`.
|
||
3. **Windows Service (recommended).** See below.
|
||
|
||
### Windows Service
|
||
|
||
Install (admin shell):
|
||
|
||
```
|
||
claude-mailbox install-service [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
|
||
```
|
||
|
||
This:
|
||
- Creates `%ProgramData%\ClaudeMailbox\` with ACLs for `LocalService`
|
||
- Seeds `mailbox.json` with the defaults (or your flag overrides) — only on first install
|
||
- Registers the service via `sc.exe create`, running as `NT AUTHORITY\LocalService` with `start= auto`
|
||
|
||
Control:
|
||
|
||
```
|
||
claude-mailbox start
|
||
claude-mailbox stop
|
||
claude-mailbox status # prints Running | Stopped | NotInstalled
|
||
claude-mailbox uninstall-service [--purge]
|
||
```
|
||
|
||
`--purge` additionally removes `%ProgramData%\ClaudeMailbox\` (config + database).
|
||
|
||
### Config precedence
|
||
|
||
```
|
||
CLI flag > mailbox.json > built-in defaults
|
||
```
|
||
|
||
The service is invoked with `serve --config C:\ProgramData\ClaudeMailbox\mailbox.json`, so editing that file and restarting the service is enough to change port/bind/db-path.
|
||
|
||
Interactive (console) runs without `--config` use `%USERPROFILE%\.claude-mailbox\mailbox.db` (unchanged from v0).
|
||
|
||
### Manual smoke test
|
||
|
||
```
|
||
claude-mailbox install-service
|
||
sc query ClaudeMailbox
|
||
claude-mailbox start
|
||
Invoke-WebRequest http://127.0.0.1:47822/health
|
||
claude-mailbox uninstall-service --purge
|
||
```
|
||
|
||
Defaults: port `47822`, bind `127.0.0.1`, database at `%ProgramData%\ClaudeMailbox\mailbox.db` (service) or `%USERPROFILE%\.claude-mailbox\mailbox.db` (console).
|
||
```
|
||
|
||
Leave everything else in the README intact. In particular, the existing `sc.exe create ...` snippet in the original "Daemon lifecycle" section is superseded by `install-service` and must be removed (it's part of the replacement above).
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs(readme): document install-service verbs and config precedence"
|
||
```
|
||
|
||
---
|
||
|
||
## Final verification checklist
|
||
|
||
Run after all tasks:
|
||
|
||
- [ ] `dotnet build ClaudeMailbox.slnx -c Release` — succeeds on local and CI (Linux).
|
||
- [ ] `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj` — all tests (existing + 13 new) pass.
|
||
- [ ] `dotnet run --project src/ClaudeMailbox -- status` on Linux → exit 2, `Service commands are Windows-only.`
|
||
- [ ] On Windows admin shell (manual):
|
||
- [ ] `claude-mailbox install-service --port 47823` → service created, `mailbox.json` seeded.
|
||
- [ ] `claude-mailbox start` → exit 0.
|
||
- [ ] `Invoke-WebRequest http://127.0.0.1:47823/health` → 200.
|
||
- [ ] `claude-mailbox status` → `Running`, exit 0.
|
||
- [ ] `claude-mailbox uninstall-service --purge` → service deleted, `%ProgramData%\ClaudeMailbox` gone.
|
||
- [ ] Push tag `v0.2.0`, watch `.gitea/workflows/release.yml` succeed, verify release at `https://git.kuns.dev/releases/ClaudeMailbox/releases` has `claude-mailbox-0.2.0-win-x64.exe` + `checksums.txt`.
|
||
|
||
## Open questions (carry-over from spec)
|
||
|
||
- **Gitea repo slug:** plan assumes `releases/ClaudeMailbox`. Confirm before first tag; if different, edit `REPO:` in both workflow files.
|
||
- **GITEA_TOKEN scope:** must include `write:repository` for release creation and asset upload.
|
||
- **Service name collisions:** if the machine already has a `ClaudeMailbox` service from manual `sc.exe create` experiments, `install-service` will fail with exit 1073 (service already exists). Run `sc.exe delete ClaudeMailbox` first.
|