Compare commits
17 Commits
ec42e8e4bd
...
83afd0ddb3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83afd0ddb3 | ||
|
|
8cdc7bac16 | ||
|
|
e3b51122ae | ||
|
|
e7407b1b3a | ||
|
|
c3e5bc2ba2 | ||
|
|
202bb692e0 | ||
|
|
dbc6844db6 | ||
|
|
ebc0319384 | ||
|
|
452dc8514b | ||
|
|
f91d3644fb | ||
|
|
5c6f4b8b6e | ||
|
|
d8f25dc01b | ||
|
|
870431d0b8 | ||
|
|
81906e7274 | ||
|
|
f397008ff5 | ||
|
|
948c6d4abe | ||
|
|
0586d67a41 |
33
.gitea/workflows/ci.yml
Normal file
33
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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 tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj -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"
|
||||||
109
.gitea/workflows/release.yml
Normal file
109
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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: Checkout tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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: Publish (win-x64, self-contained, single-file)
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="$DOTNET_ROOT:$PATH"
|
||||||
|
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:
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
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:
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd 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."
|
||||||
52
README.md
52
README.md
@@ -33,18 +33,58 @@ Put the resulting `claude-mailbox.exe` on your `PATH`.
|
|||||||
|
|
||||||
## Daemon lifecycle
|
## Daemon lifecycle
|
||||||
|
|
||||||
The daemon is a normal console process. Pick whichever level of automation you want:
|
Pick whichever level of automation you want:
|
||||||
|
|
||||||
1. **Manual.** Open a terminal, run `claude-mailbox serve`, leave it open. Stops when you close the window.
|
1. **Manual.** `claude-mailbox serve` in a terminal.
|
||||||
2. **Startup shortcut.** Drop a shortcut to `claude-mailbox serve` in `shell:startup` — starts on login.
|
2. **Startup shortcut.** Shortcut to `claude-mailbox serve` in `shell:startup`.
|
||||||
3. **Windows Service.** `sc.exe create ClaudeMailbox binPath= "C:\path\to\claude-mailbox.exe serve" start= auto` — same pattern ClaudeDo uses.
|
3. **Windows Service (recommended).** See below.
|
||||||
|
|
||||||
Defaults: port `47822`, bind `127.0.0.1`, database at `%USERPROFILE%\.claude-mailbox\mailbox.db`. All overridable:
|
### Windows Service
|
||||||
|
|
||||||
|
Install (admin shell):
|
||||||
|
|
||||||
```
|
```
|
||||||
claude-mailbox serve [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
|
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).
|
||||||
|
|
||||||
## Use from a Claude session
|
## Use from a Claude session
|
||||||
|
|
||||||
Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values):
|
Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
|||||||
|
# Gitea Release Flow + Windows Service Support
|
||||||
|
|
||||||
|
**Date:** 2026-04-24
|
||||||
|
**Status:** Approved (ready for implementation plan)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Two coupled capabilities for ClaudeMailbox:
|
||||||
|
|
||||||
|
1. **Gitea Release Flow.** Tag-driven CI that publishes a self-contained `win-x64` single-file binary plus SHA256 checksums to a Gitea release.
|
||||||
|
2. **Windows Service Support.** The binary self-installs as a Windows Service via new `install-service` / `uninstall-service` / `start` / `stop` verbs, seeded from a `mailbox.json` config file. The ClaudeDo Installer can then provision ClaudeMailbox directly using this stable verb surface.
|
||||||
|
|
||||||
|
Both are needed so that a downstream installer (ClaudeDo) can fetch, verify, and register ClaudeMailbox as a long-running service without hand-crafted `sc.exe` calls at the installer side.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Linux / macOS service integration (`systemd`, `launchd`). Single-platform (`win-x64`) only in v1.
|
||||||
|
- Cross-machine / non-loopback deployment.
|
||||||
|
- Auto-update of the service binary (ClaudeDo owns update orchestration via its existing `SelfUpdater` pattern).
|
||||||
|
- Per-user service installation. Service runs as `NT AUTHORITY\LocalService`; DB lives under `%ProgramData%`.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
tag push ──► .gitea/workflows/release.yml
|
||||||
|
│
|
||||||
|
├─ dotnet publish (win-x64, self-contained, single-file)
|
||||||
|
├─ sha256 → checksums.txt
|
||||||
|
└─ POST /api/v1/repos/.../releases
|
||||||
|
+ upload assets
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
claude-mailbox-${VERSION}-win-x64.exe
|
||||||
|
checksums.txt
|
||||||
|
│
|
||||||
|
│ (consumed by ClaudeDo Installer)
|
||||||
|
▼
|
||||||
|
claude-mailbox.exe install-service [--port] [--bind] [--db-path]
|
||||||
|
│
|
||||||
|
├─ seed %ProgramData%\ClaudeMailbox\mailbox.json
|
||||||
|
├─ ACL %ProgramData%\ClaudeMailbox\ for LocalService
|
||||||
|
└─ sc.exe create ClaudeMailbox
|
||||||
|
binPath= "<exe> serve --config <mailbox.json>"
|
||||||
|
start= auto obj= "NT AUTHORITY\LocalService"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ClaudeMailbox Windows Service
|
||||||
|
(reads mailbox.json, hosts MCP + REST)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Section 1 — Gitea Release Flow
|
||||||
|
|
||||||
|
### Trigger
|
||||||
|
- `push` on tags matching `v*`.
|
||||||
|
- Separate `ci.yml` runs `dotnet build` + `dotnet test` on every push to `main` (no release).
|
||||||
|
|
||||||
|
### Workflow Skeleton
|
||||||
|
`.gitea/workflows/release.yml` modelled after `C:\Private\ClaudeDo\.gitea\workflows\release.yml`:
|
||||||
|
|
||||||
|
- `runs-on: ubuntu-latest`
|
||||||
|
- `env: DOTNET_ROOT: /home/mika/.dotnet`, `GITEA_API: https://git.kuns.dev/api/v1`, `REPO: releases/ClaudeMailbox` (exact repo slug to confirm at implementation time)
|
||||||
|
- Steps:
|
||||||
|
1. **Resolve version** — strip `v` prefix from `github.ref_name` → `$VERSION`
|
||||||
|
2. **Prepare workspace** — `mktemp -d`
|
||||||
|
3. **Checkout tag** — `git clone --depth 1 --branch $TAG` using `secrets.GITEA_TOKEN`
|
||||||
|
4. **Publish** — `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`
|
||||||
|
5. **Package assets** — rename produced exe to `claude-mailbox-${VERSION}-win-x64.exe`, write `checksums.txt` via `sha256sum`
|
||||||
|
6. **Create Gitea Release** — `curl POST ${GITEA_API}/repos/${REPO}/releases` with `tag_name`, `name`, `draft:false`, `prerelease:false`, `target_commitish:main`
|
||||||
|
7. **Upload assets** — `curl POST .../releases/${RELEASE_ID}/assets?name=<file>` for each asset
|
||||||
|
8. **Cleanup** — `rm -rf $WORK` with `if: always()`
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
`Directory.Build.props` gains a MinVer reference so `$VERSION` from the tag is baked into `AssemblyVersion`, `FileVersion`, `InformationalVersion`. Same approach as ClaudeDo (`MinVerVersionOverride` property). Default local dev version: `0.0.0-dev`.
|
||||||
|
|
||||||
|
Whether to pull in the `MinVer` NuGet package explicitly or use .NET SDK's built-in `Version` property with `-p:Version=$VERSION` is an implementation choice made during the plan phase — ClaudeDo uses `MinVerVersionOverride` which implies the MinVer package is present.
|
||||||
|
|
||||||
|
### Release Assets (stable contract)
|
||||||
|
- `claude-mailbox-${VERSION}-win-x64.exe` — self-contained single-file binary
|
||||||
|
- `checksums.txt` — `sha256sum` output for the exe
|
||||||
|
|
||||||
|
No ZIP (single artifact, simpler discovery).
|
||||||
|
|
||||||
|
### CI Workflow
|
||||||
|
`.gitea/workflows/ci.yml`:
|
||||||
|
- Trigger: push to `main`, PRs to `main`
|
||||||
|
- Steps: checkout → setup dotnet → `dotnet build` → `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj`
|
||||||
|
- No publish, no release.
|
||||||
|
|
||||||
|
## Section 2 — Windows Service Support
|
||||||
|
|
||||||
|
### Project Changes
|
||||||
|
`src/ClaudeMailbox/ClaudeMailbox.csproj` adds:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.*" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Host Wiring
|
||||||
|
`ServerHost.CreateBuilder` adds (before `Build()`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");
|
||||||
|
```
|
||||||
|
|
||||||
|
No-op when launched as console app; enables Windows Service lifetime when SCM starts the process.
|
||||||
|
|
||||||
|
### New CLI Verbs
|
||||||
|
Dispatch in `Program.cs` extended. New module `src/ClaudeMailbox/Cli/ServiceCommands.cs`:
|
||||||
|
|
||||||
|
| Verb | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `install-service [--port] [--bind] [--db-path]` | Admin-only. Seed `mailbox.json` (if missing). ACL ProgramData dir. `sc.exe create`. `sc.exe description`. |
|
||||||
|
| `uninstall-service [--purge]` | Admin-only. `sc.exe stop` (best-effort), `sc.exe delete`. `--purge` removes `%ProgramData%\ClaudeMailbox`. |
|
||||||
|
| `start` | `sc.exe start ClaudeMailbox`. |
|
||||||
|
| `stop` | `sc.exe stop ClaudeMailbox`. |
|
||||||
|
| `status` | `sc.exe query ClaudeMailbox`, parse `STATE` line, print single-word status. |
|
||||||
|
|
||||||
|
Service commands:
|
||||||
|
- Gate on `OperatingSystem.IsWindows()`. On non-Windows: exit 2, message `"Service commands are Windows-only."`
|
||||||
|
- Gate on admin privilege (WindowsIdentity + WindowsPrincipal). On missing: exit 5, message `"install-service requires Administrator."`
|
||||||
|
- Shell out to `sc.exe` via `Process.Start(new ProcessStartInfo { ... RedirectStandardOutput = true })`, capture exit code, surface stderr on failure.
|
||||||
|
|
||||||
|
### install-service Flow (concrete)
|
||||||
|
1. Admin check.
|
||||||
|
2. `Directory.CreateDirectory(@"C:\ProgramData\ClaudeMailbox")` (idempotent).
|
||||||
|
3. Apply ACL: add `LocalService` with `Modify` rights on that directory (`DirectorySecurity` + `FileSystemAccessRule`).
|
||||||
|
4. If `mailbox.json` missing: write seeded JSON with CLI-flag-overridable values:
|
||||||
|
```json
|
||||||
|
{ "port": 47822, "bind": "127.0.0.1", "dbPath": "C:\\ProgramData\\ClaudeMailbox\\mailbox.db" }
|
||||||
|
```
|
||||||
|
5. Resolve current exe path via `Environment.ProcessPath`.
|
||||||
|
6. `sc.exe create ClaudeMailbox binPath= "\"<exe>\" serve --config \"C:\ProgramData\ClaudeMailbox\mailbox.json\"" start= auto DisplayName= "Claude Mailbox" obj= "NT AUTHORITY\LocalService"`
|
||||||
|
7. `sc.exe description ClaudeMailbox "MCP mailbox server for parallel Claude sessions"`
|
||||||
|
|
||||||
|
Service name is fixed (`ClaudeMailbox`) in v1 — no multi-instance support.
|
||||||
|
|
||||||
|
### uninstall-service Flow
|
||||||
|
1. Admin check.
|
||||||
|
2. `sc.exe stop ClaudeMailbox` (ignore failure if not running).
|
||||||
|
3. `sc.exe delete ClaudeMailbox`.
|
||||||
|
4. If `--purge`: delete `%ProgramData%\ClaudeMailbox` recursively (only if empty of non-ours files, or unconditionally — default to unconditional with explicit `--purge` opt-in).
|
||||||
|
|
||||||
|
## Section 3 — Config File + Precedence
|
||||||
|
|
||||||
|
### File
|
||||||
|
`src/ClaudeMailbox/Config/FileConfig.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class FileConfig
|
||||||
|
{
|
||||||
|
public int? Port { get; set; }
|
||||||
|
public string? Bind { get; set; }
|
||||||
|
public string? DbPath { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loader
|
||||||
|
`FileConfig.LoadOrDefault(string? explicitPath)`:
|
||||||
|
- If `explicitPath` given: must exist, else throw with clear message.
|
||||||
|
- Else: probe `%ProgramData%\ClaudeMailbox\mailbox.json`. If absent, return empty `FileConfig`.
|
||||||
|
- Parse via `System.Text.Json.JsonSerializer` with `PropertyNameCaseInsensitive = true`.
|
||||||
|
|
||||||
|
### Precedence
|
||||||
|
In `Program.cs` (before building `DaemonConfig`):
|
||||||
|
|
||||||
|
1. CLI flag (`--port`, `--bind`, `--db-path`)
|
||||||
|
2. Config file (explicit via `--config <path>` OR default `%ProgramData%\ClaudeMailbox\mailbox.json` if present)
|
||||||
|
3. Hardcoded defaults in `DaemonConfig`
|
||||||
|
|
||||||
|
Extract helper `DaemonConfig BuildDaemonConfig(string[] args, FileConfig file)` for testability.
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
When `--config` is not passed AND no ProgramData config exists, behavior is unchanged: daemon uses `%USERPROFILE%\.claude-mailbox\mailbox.db` and default port/bind. Existing interactive users are unaffected.
|
||||||
|
|
||||||
|
### Service CmdLine
|
||||||
|
`install-service` always bakes `--config C:\ProgramData\ClaudeMailbox\mailbox.json` into the service's `binPath`, so the service has no dependency on working directory, user profile, or environment.
|
||||||
|
|
||||||
|
## Section 4 — Tests
|
||||||
|
|
||||||
|
### New Unit Tests
|
||||||
|
- `tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs`
|
||||||
|
- Round-trip JSON with all fields.
|
||||||
|
- Missing fields deserialize to `null` (optional semantics).
|
||||||
|
- Malformed JSON throws with actionable message.
|
||||||
|
- `tests/ClaudeMailbox.Tests/Config/ConfigPrecedenceTests.cs`
|
||||||
|
- CLI flag wins over file value.
|
||||||
|
- File value wins over default.
|
||||||
|
- Default used when neither file nor CLI provides it.
|
||||||
|
- Mixed: `--port` from CLI, `dbPath` from file, `bind` from default.
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- Service install/uninstall tests. `sc.exe` requires Administrator, is Windows-only, and not runnable on `ubuntu-latest` CI. A manual smoke-test protocol is documented in README:
|
||||||
|
```
|
||||||
|
claude-mailbox.exe install-service
|
||||||
|
sc query ClaudeMailbox
|
||||||
|
Invoke-WebRequest http://127.0.0.1:47822/health
|
||||||
|
claude-mailbox.exe uninstall-service --purge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-Platform Build
|
||||||
|
Service-related code must compile on Linux (CI runner). Use:
|
||||||
|
- `Microsoft.Extensions.Hosting.WindowsServices` — NuGet package is cross-platform; `UseWindowsService()` is a no-op on non-Windows.
|
||||||
|
- `sc.exe` invocations guarded by `OperatingSystem.IsWindows()`.
|
||||||
|
- `DirectorySecurity` / `FileSystemAccessRule` are in `System.Security.AccessControl` — available on non-Windows but throw on use. Guard all calls.
|
||||||
|
|
||||||
|
## Section 5 — ClaudeDo Installer Contract
|
||||||
|
|
||||||
|
This section is **informational** — the work is on the ClaudeDo side, not in this repo. Listed here to make the contract explicit.
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
`GET https://git.kuns.dev/api/v1/repos/releases/ClaudeMailbox/releases/latest` returns `assets[].browser_download_url`. Reuse ClaudeDo's `ReleaseClient` + `ChecksumVerifier` + `VersionComparer`.
|
||||||
|
|
||||||
|
### Installer Steps (new in ClaudeDo)
|
||||||
|
1. Download `claude-mailbox-${VERSION}-win-x64.exe` → `%ProgramFiles%\ClaudeMailbox\claude-mailbox.exe`.
|
||||||
|
2. Verify SHA256 against `checksums.txt`.
|
||||||
|
3. Run `claude-mailbox.exe install-service` (elevated). This seeds `mailbox.json`, ACLs ProgramData, creates the service.
|
||||||
|
4. Run `claude-mailbox.exe start` (or `sc.exe start ClaudeMailbox`).
|
||||||
|
5. Record entry in ClaudeDo's `InstallManifest` (version + path).
|
||||||
|
6. On uninstall: `claude-mailbox.exe uninstall-service --purge` → delete `%ProgramFiles%\ClaudeMailbox\`.
|
||||||
|
|
||||||
|
### Stable Contract
|
||||||
|
- Verbs: `install-service`, `uninstall-service`, `start`, `stop`, `status`.
|
||||||
|
- Flags on `install-service`: `--port`, `--bind`, `--db-path` (all optional).
|
||||||
|
- Service name: `ClaudeMailbox`.
|
||||||
|
- Config path: `%ProgramData%\ClaudeMailbox\mailbox.json`.
|
||||||
|
|
||||||
|
Any changes to the above require coordinated updates in the ClaudeDo installer.
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
- `src/ClaudeMailbox/ClaudeMailbox.csproj` — add `Microsoft.Extensions.Hosting.WindowsServices`.
|
||||||
|
- `src/ClaudeMailbox/ServerHost.cs` — `UseWindowsService()` wiring.
|
||||||
|
- `src/ClaudeMailbox/Program.cs` — dispatch new verbs, integrate `FileConfig` precedence.
|
||||||
|
- `src/ClaudeMailbox/Cli/ServiceCommands.cs` — **new**. Verb handlers.
|
||||||
|
- `src/ClaudeMailbox/Config/FileConfig.cs` — **new**. Config file model + loader.
|
||||||
|
- `tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs` — **new**.
|
||||||
|
- `tests/ClaudeMailbox.Tests/Config/ConfigPrecedenceTests.cs` — **new**.
|
||||||
|
- `.gitea/workflows/release.yml` — **new**. Tag-triggered release.
|
||||||
|
- `.gitea/workflows/ci.yml` — **new**. Build + test on main.
|
||||||
|
- `Directory.Build.props` — MinVer integration (or `Version` fallback).
|
||||||
|
- `README.md` — document `install-service` / `uninstall-service`, config file, manual smoke test.
|
||||||
|
|
||||||
|
## Open Questions for Implementation Plan
|
||||||
|
|
||||||
|
- Exact Gitea repo slug (confirm `releases/ClaudeMailbox` or other).
|
||||||
|
- MinVer package vs. `-p:Version=$VERSION` — align with ClaudeDo's current convention.
|
||||||
|
- Whether `status` verb should print parsed `Running | Stopped | NotInstalled` or raw `sc query` output. Recommendation: parsed + exit codes 0/1/2.
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
219
src/ClaudeMailbox/Cli/ServiceCommands.cs
Normal file
219
src/ClaudeMailbox/Cli/ServiceCommands.cs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.AccessControl;
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 portStr = ClientCommands.GetOption(args, "--port");
|
||||||
|
var port = int.TryParse(portStr, out var p) ? p : 47822;
|
||||||
|
var bind = ClientCommands.GetOption(args, "--bind") ?? "127.0.0.1";
|
||||||
|
var dbPath = ClientCommands.GetOption(args, "--db-path") ?? defaultDbPath;
|
||||||
|
|
||||||
|
var json = $$"""
|
||||||
|
{
|
||||||
|
"port": {{port}},
|
||||||
|
"bind": {{System.Text.Json.JsonSerializer.Serialize(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)
|
||||||
|
{
|
||||||
|
if (createExit == 1073)
|
||||||
|
Console.Error.WriteLine($"Service '{ServiceName}' already exists. Run 'claude-mailbox uninstall-service' first.");
|
||||||
|
else
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/ClaudeMailbox/Config/ConfigResolver.cs
Normal file
30
src/ClaudeMailbox/Config/ConfigResolver.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/ClaudeMailbox/Config/FileConfig.cs
Normal file
41
src/ClaudeMailbox/Config/FileConfig.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,16 +7,20 @@ if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
|
|||||||
return await ClientCommands.RunAsync(args);
|
return await ClientCommands.RunAsync(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip the optional leading "serve" verb so WebApplication.CreateBuilder
|
if (args.Length > 0 && args[0] is "install-service" or "uninstall-service" or "start" or "stop" or "status")
|
||||||
// doesn't try to treat it as an unknown flag.
|
{
|
||||||
|
return await ServiceCommands.RunAsync(args);
|
||||||
|
}
|
||||||
|
|
||||||
var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args;
|
var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args;
|
||||||
|
|
||||||
var cfg = new DaemonConfig
|
var explicitConfig = ClientCommands.GetOption(serveArgs, "--config");
|
||||||
{
|
var defaultConfig = Path.Combine(
|
||||||
Port = ParseInt(serveArgs, "--port", DaemonConfig.DefaultPort),
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
BindAddress = ClientCommands.GetOption(serveArgs, "--bind") ?? DaemonConfig.DefaultBindAddress,
|
"ClaudeMailbox", "mailbox.json");
|
||||||
DbPath = Paths.Expand(ClientCommands.GetOption(serveArgs, "--db-path") ?? Paths.DefaultDbPath()),
|
|
||||||
};
|
var fileConfig = FileConfig.Load(explicitConfig, defaultConfig);
|
||||||
|
var cfg = ConfigResolver.Build(serveArgs, fileConfig);
|
||||||
|
|
||||||
var builder = ServerHost.CreateBuilder(cfg, serveArgs);
|
var builder = ServerHost.CreateBuilder(cfg, serveArgs);
|
||||||
builder.WebHost.UseUrls(cfg.BaseUrl);
|
builder.WebHost.UseUrls(cfg.BaseUrl);
|
||||||
@@ -38,10 +42,4 @@ catch (IOException ex) when (ex.Message.Contains("address already in use", Strin
|
|||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int ParseInt(string[] args, string name, int fallback)
|
|
||||||
{
|
|
||||||
var raw = ClientCommands.GetOption(args, name);
|
|
||||||
return int.TryParse(raw, out var v) ? v : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class Program { }
|
public partial class Program { }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ClaudeMailbox.Data.Repositories;
|
|||||||
using ClaudeMailbox.Http;
|
using ClaudeMailbox.Http;
|
||||||
using ClaudeMailbox.Mcp;
|
using ClaudeMailbox.Mcp;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Hosting.WindowsServices;
|
||||||
|
|
||||||
namespace ClaudeMailbox;
|
namespace ClaudeMailbox;
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ public static class ServerHost
|
|||||||
public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null)
|
public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null)
|
||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());
|
var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());
|
||||||
|
builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");
|
||||||
|
|
||||||
builder.Services.AddSingleton(cfg);
|
builder.Services.AddSingleton(cfg);
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|||||||
59
tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs
Normal file
59
tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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(Paths.Expand("/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(Paths.Expand("/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs
Normal file
99
tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user