34 Commits

Author SHA1 Message Date
b623651a5d Merge branch 'main' into feat/release-workflow 2026-04-15 09:27:21 +00:00
Mika Kuns
6b1b920149 feat(installer): download-mode rewrite + Gitea Releases pipeline
Rewrites ClaudeDo.Installer to fetch prebuilt binaries from
git.kuns.dev/releases/ClaudeDo instead of building from source.

- Async InstallModeDetector: FreshInstall / Update / Config
- DownloadAndExtractStep with SHA256 verify + scratch-dir extract
- UninstallRunner: stop-service / delete / full ~/.todo-app removal
  with path guard + partial-failure reporting
- Config view: Save / Repair / Uninstall buttons
- Self-contained single-file publish for the installer itself
- 29 xUnit tests in new ClaudeDo.Installer.Tests project

Spec: docs/superpowers/specs/2026-04-15-installer-download-mode-design.md
Plan: docs/superpowers/plans/2026-04-15-installer-download-mode.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:22:01 +02:00
Mika Kuns
b7a8d78d4a chore(installer): remove orphaned InstallerService DI registration 2026-04-15 11:10:24 +02:00
Mika Kuns
b5455a1965 feat(installer): mode-aware wizard page list + Update-mode step pipeline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:07:03 +02:00
Mika Kuns
5d42438a72 fix(installer): UninstallRunner abort-on-stop-fail + path guard + partial-failure reporting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:56:39 +02:00
Mika Kuns
2898bec314 feat(installer): Config view — Save/Repair/Uninstall commands + footer buttons 2026-04-15 10:31:24 +02:00
Mika Kuns
ac38ea8c34 feat(installer): add UninstallRunner (service + shortcuts + dirs) 2026-04-15 10:29:25 +02:00
Mika Kuns
8d2f7e9907 fix(installer): null-defensive WelcomePage heading + guard unreachable modes 2026-04-15 10:27:30 +02:00
Mika Kuns
da1fe2109a feat(installer): rewrite WelcomePage for download-mode + update heading
Removes SourceDirectory field (no longer in InstallContext), adds
dynamic Heading/Subheading/InstallDirEditable for FreshInstall vs Update
mode, and updates XAML to match sibling page style.
2026-04-15 10:14:38 +02:00
Mika Kuns
5e432a4a27 fix(installer): fall back to Config on detection timeout when install.json exists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:10:02 +02:00
Mika Kuns
01c29bb6f6 feat(installer): async mode detection + mode-aware DI wiring 2026-04-15 10:01:20 +02:00
Mika Kuns
12e532718c fix(installer): wrap WriteInstallManifestStep I/O in try/catch like sibling steps 2026-04-15 09:58:16 +02:00
Mika Kuns
fe913ae5ef build(installer): add single-file self-contained publish properties 2026-04-15 09:55:53 +02:00
Mika Kuns
4fab0481c4 refactor(installer): replace SourceDirectory with Mode/Version fields in InstallContext 2026-04-15 09:54:57 +02:00
Mika Kuns
0989176127 refactor(installer): remove source-build steps (replaced by DownloadAndExtractStep) 2026-04-15 09:54:22 +02:00
Mika Kuns
548251841f feat(installer): add WriteInstallManifestStep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:48:29 +02:00
Mika Kuns
ea32a74baa fix(installer): harden DownloadAndExtractStep per review 2026-04-15 09:43:27 +02:00
Mika Kuns
c1e330164e feat(installer): add DownloadAndExtractStep with SHA256 verify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:37:04 +02:00
Mika Kuns
5b4af29420 fix(installer): check exit code (not stdout) for ERROR_SERVICE_ALREADY_RUNNING 2026-04-15 09:32:26 +02:00
Mika Kuns
d87de152e0 feat(installer): add Stop/StartServiceStep sc.exe wrappers 2026-04-15 09:27:54 +02:00
Mika Kuns
b4dc9509cb test(installer): pin 'unparseable version = Config' behavior + document IsNewer limits 2026-04-15 09:26:18 +02:00
Mika Kuns
97fb215ce6 feat(installer): replace sync ModeDetector with async InstallModeDetector
Placeholder edit to App.xaml.cs to keep the project building until Task 11
wires the new async detector.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:19:16 +02:00
Mika Kuns
83d7058b32 fix(installer): propagate cancellation + defensive asset parsing in ReleaseClient
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:15:56 +02:00
Mika Kuns
5603fd458d feat(installer): add IReleaseClient + Gitea ReleaseClient 2026-04-15 09:10:02 +02:00
Mika Kuns
d0c0e2ce1f feat(installer): add ChecksumVerifier (SHA256 + checksums.txt parser) 2026-04-15 09:03:08 +02:00
Mika Kuns
2fc6924dcb test(installer): add InstallManifest wrong-shape json test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:59:51 +02:00
Mika Kuns
921e626208 feat(installer): add InstallManifest + json-backed store 2026-04-15 08:53:52 +02:00
Mika Kuns
c23ed94817 test(installer): address review — drop UseWPF, thread-safe FakeHttpMessageHandler 2026-04-15 08:51:12 +02:00
Mika Kuns
2d34afb2e5 test(installer): scaffold ClaudeDo.Installer.Tests project 2026-04-15 08:46:17 +02:00
Mika Kuns
c0bd46542a docs(installer): add download-mode implementation plan
17-task TDD plan for rewriting the installer to fetch binaries from
releases/ClaudeDo on git.kuns.dev. Covers InstallManifest, ReleaseClient,
InstallModeDetector, DownloadAndExtractStep, Config/Repair/Uninstall,
and the publish-time single-file self-contained settings.

Workflow file is out of scope (handled by VPS Claude).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:37:07 +02:00
Mika Kuns
0498fbae47 docs(installer): finalize decisions — self-contained, auto-check, full uninstall
- App + Worker now self-contained (zero .NET runtime dep on target)
- Collapse Manage mode into "update check -> Config view" on every
  subsequent launch; Repair + Uninstall become buttons in Config
- Uninstall removes {InstallDir} and ~/.todo-app in full (no prompt
  to keep data) — matches user's stated intent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:21:12 +02:00
Mika Kuns
43a10cff95 docs(installer): pin release target to releases/ClaudeDo
VPS confirmed the releases/ org is world-readable without auth; the
ClaudeDo source already lives at git.kuns.dev/releases/ClaudeDo, so the
workflow uses the built-in gitea.token (no cross-org PAT needed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:14:28 +02:00
Mika Kuns
bd7d5940a2 docs(installer): add download-mode + Gitea Releases design spec
Design for rewriting the installer to fetch prebuilt binaries from Gitea
Releases on git.kuns.dev instead of building from source. Covers the
Actions workflow, release artifact layout, install.json marker file,
Install/Update/Manage mode detection, and the new DownloadAndExtractStep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:03:40 +02:00
CubeGameLP
78831b2263 feat(installer): add WPF installer/configurator project
Standalone WPF app (ClaudeDo.Installer) that handles full installation
and ongoing configuration of ClaudeDo. Two modes: wizard for first run,
tabbed settings panel for subsequent launches. Page-based extensibility
via IInstallerPage interface — adding new config sections requires only
one new class.

Install pipeline: dotnet publish, deploy binaries, write configs, init
DB (via SchemaInitializer from ClaudeDo.Data), register Windows Service,
create shortcuts. Dark theme matching the Avalonia app (forest teal accent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:01:03 +02:00
64 changed files with 6318 additions and 0 deletions

View File

@@ -4,9 +4,11 @@
<Project Path="src/ClaudeDo.Data/ClaudeDo.Data.csproj" />
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
</Folder>
</Solution>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,316 @@
# Installer: Download-Mode + Gitea Releases
Date: 2026-04-15
Status: Design — awaiting implementation plan
## Goal
Turn `ClaudeDo.Installer` into a self-contained tool that any user can run on
any Windows machine to install, update, reconfigure, repair, or uninstall
ClaudeDo. The installer pulls prebuilt binaries from a Gitea release on
`git.kuns.dev` instead of building from source.
End-user experience:
1. Download `ClaudeDo.Installer-<version>.exe` from the releases page.
2. Run it.
3. Done — no .NET SDK, no source checkout, no manual steps.
## Non-Goals
- Code signing the installer or the app binaries (future concern).
- Cross-platform installs (Windows-only, same as today).
- In-app update notifications (the installer handles updates when run; the app
does not self-update).
- Arbitrary-version selection UI. Installer always targets "latest" release.
- A package-manager listing (winget/Chocolatey/Scoop). Future, separate spec.
## Current State (2026-04-15)
The existing installer (`src/ClaudeDo.Installer/`) is a WPF wizard that only
works from inside a source checkout on a machine with the .NET SDK installed:
- `PublishAppStep` runs `dotnet publish src/ClaudeDo.App/...`
- `PublishWorkerStep` runs `dotnet publish src/ClaudeDo.Worker/...`
- `DeployBinariesStep` copies `bin/Release/.../publish` into the install dir
- Subsequent steps (`WriteConfigStep`, `InitDatabaseStep`,
`CreateShortcutsStep`, `RegisterServiceStep`) are fine to keep.
The installer also contains a partial "Settings" window
(`Views/SettingsWindow.xaml`, `Views/SettingsViewModel.cs`) — that wiring is
reused for the Config view shown on subsequent launches (see Mode detection
below).
## High-Level Design
Two pieces, each small:
**1) A Gitea Actions workflow** that, on every `v*` tag push, builds the App,
Worker, and Installer; packages them; and creates a Gitea Release on the
public repo at `git.kuns.dev/releases/ClaudeDo`.
The `releases/` org on the Gitea instance is world-readable without auth;
private work (including the source repo, if you want) lives under `kuns/*`
which is never public. The installer only needs to hit `releases/ClaudeDo`.
**2) An installer rewrite** that replaces the three publish/deploy steps with
a single `DownloadAndExtractStep`, detects existing installs via a marker
file, and on subsequent launches checks the Gitea API for updates before
deciding whether to show the Update flow or jump straight to the Config view.
## Release Artifacts
Each `v*` tag produces a Gitea Release with three assets:
```
ClaudeDo-<version>-win-x64.zip # contains /app and /worker subdirs
ClaudeDo.Installer-<version>.exe # self-contained installer (no .NET needed)
checksums.txt # SHA256 of the above
```
Decisions:
- **One combined app+worker zip** (not two separate). Reasons: one download,
one extract, guaranteed version-locked pair.
- **Self-contained installer exe** — user does not need .NET installed.
- **App + Worker: self-contained** (`--self-contained true`, `-r win-x64`).
Zero runtime dependency on the target machine, at the cost of a larger
download (~100 MB combined zip). Decision: acceptable trade-off — the
installer is one-click, not per-user-problem-to-debug.
- **Checksums file** — plain text, one line per asset (`<sha256> <filename>`),
verified by installer before extract.
The "latest installer exe" URL is stable:
```
https://git.kuns.dev/releases/ClaudeDo/releases/latest/download/ClaudeDo.Installer-<version>.exe
```
(Gitea also exposes `/releases/download/<tag>/<filename>` for specific
versions.)
## Gitea Actions Workflow
File: `.gitea/workflows/release.yml`
- **Trigger:** `push` on tags matching `v*`
- **Runner:** Linux container with .NET 8 SDK (`dotnet publish -r win-x64`
works cross-platform). The installer itself requires Windows to run, but
`dotnet publish` can target `win-x64` from Linux.
- **Steps:**
1. Checkout
2. Setup .NET 8 SDK
3. Derive version from tag (`${{ gitea.ref_name }}` without the `v` prefix)
4. `dotnet publish src/ClaudeDo.App -c Release -r win-x64 --self-contained true /p:Version=$VERSION -o out/app`
5. `dotnet publish src/ClaudeDo.Worker -c Release -r win-x64 --self-contained true /p:Version=$VERSION -o out/worker`
6. `dotnet publish src/ClaudeDo.Installer -c Release -r win-x64 --self-contained true /p:Version=$VERSION /p:PublishSingleFile=true -o out/installer`
7. Zip `out/app` + `out/worker` as `ClaudeDo-<version>-win-x64.zip` with
`app/` and `worker/` as top-level dirs
8. Copy `out/installer/ClaudeDo.Installer.exe` to
`ClaudeDo.Installer-<version>.exe`
9. Generate `checksums.txt` (`sha256sum` both files)
10. Create release via Gitea API using the built-in `${{ gitea.token }}`
(this token has repo write scope automatically on Actions runs). Release
name = tag name. Release notes = `git log` summary between previous tag
and this one (nice-to-have).
The workflow needs **no custom secrets**`gitea.token` is sufficient for
creating releases on its own repo.
## Installer Changes
### New: `install.json` marker file
Written at the end of every successful install or update to
`{InstallDir}/install.json`:
```json
{
"version": "0.2.0",
"installDir": "C:\\Program Files\\ClaudeDo",
"workerDir": "C:\\Program Files\\ClaudeDo\\worker",
"installedAt": "2026-04-15T12:34:56Z"
}
```
The installer reads this on startup (from the default install dir, or a
path supplied via CLI arg) to decide which mode to run in.
### Launch flow (`InstallModeDetector`)
On every launch, the installer checks for `install.json` first:
```
install.json absent?
-> Install mode: Welcome -> Paths -> UiSettings -> Service -> Install
(writes install.json at the end)
install.json present?
-> Query https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest
(short timeout; if offline, treat as "no update available")
latest.tag_name > installed.version
-> Update mode: Welcome ("Update v0.1.0 -> v0.2.0, Update / Later")
If user accepts -> Install steps (download + swap service)
If user declines -> fall through to Config view
latest.tag_name <= installed.version (or API unreachable)
-> Config view: directly open Paths/UiSettings/Service tabs,
prefilled from existing ~/.todo-app/*.json.
Action buttons: Save · Repair · Uninstall.
```
Key properties:
- **First run = wizard**, as today — no behavior change for new users.
- **Every subsequent run = update check first**, then either offer update or
drop straight into Config. No "Manage page" with a menu of actions — the
Config view *is* the default, and Repair/Uninstall are buttons on it.
- **Offline / API error = not fatal**: if the release endpoint can't be
reached, the installer silently skips the update check and opens Config.
The user is never blocked from managing an existing install by a network
issue.
- **Downgrade** (installed version > latest) is treated the same as "no
update available" — we don't ever offer a downgrade.
The installer's own version (shown for reference in Config) comes from its
assembly (`AssemblyInformationalVersion`), set by the workflow's
`/p:Version=$VERSION`. The *installed* version comes from `install.json`.
### New step: `DownloadAndExtractStep`
Replaces `PublishAppStep`, `PublishWorkerStep`, `DeployBinariesStep`.
```
1. GET https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest
Parse tag_name and asset URLs for:
- ClaudeDo-<ver>-win-x64.zip
- checksums.txt
2. Download both to %TEMP%\ClaudeDo-install-<guid>\
3. Parse checksums.txt, verify SHA256 of the zip. Fail hard if mismatch.
4. (Update mode only) Stop Worker service via sc.exe stop ClaudeDoWorker.
Wait up to 30s for it to actually stop. If it won't stop, fail.
5. (Update mode only) Delete contents of {InstallDir}/app and
{InstallDir}/worker, but leave the directories and install.json in place.
6. Extract zip: /app -> {InstallDir}/app, /worker -> {InstallDir}/worker.
7. (Update mode only) Start service again via sc.exe start ClaudeDoWorker.
8. Progress is reported via IProgress<string> — the UI already shows it.
```
Config files (`~/.todo-app/*.json`) and DB (`~/.todo-app/todo.db`) live
outside `InstallDir` and are never touched by this step — updates are
naturally non-destructive.
### Update mode — which steps run
- **Yes:** `DownloadAndExtractStep`
- **No:** `WriteConfigStep` (user already has config — we don't overwrite)
- **No:** `InitDatabaseStep` (DB exists)
- **No:** `CreateShortcutsStep` (already there; Repair can re-run this)
- **Conditional:** `RegisterServiceStep` only if service is not currently
registered (covers someone who unregistered it manually)
### Config view — actions
- **Save** (primary): writes the Paths / UiSettings / Service fields to
`~/.todo-app/*.json`. If worker config changed, prompts "Restart service?"
and calls `sc stop` / `sc start` if accepted. No download.
- **Repair:** re-download + extract (same as Update flow), re-create
shortcuts, re-register service. Leaves config/DB alone. Confirmation
dialog before starting.
- **Uninstall:** confirmation dialog ("This removes ClaudeDo *and* all of
your tasks, config, and database. Type UNINSTALL to confirm."). On
confirm:
1. Stop + unregister service (`sc stop`, `sc delete ClaudeDoWorker`)
2. Remove Start Menu / Desktop shortcuts
3. Delete `{InstallDir}` (including `install.json`)
4. Delete `~/.todo-app` in full (config + DB + logs)
5. Exit
Everything is removed. No "keep my data" option — that was explicitly
declined in the design discussion.
### Files to add
```
src/ClaudeDo.Installer/Core/InstallModeDetector.cs
src/ClaudeDo.Installer/Core/ReleaseClient.cs // Gitea API + downloads
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
src/ClaudeDo.Installer/Core/InstallManifest.cs // read/write install.json
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
src/ClaudeDo.Installer/Steps/StopServiceStep.cs // used in Update+Uninstall
src/ClaudeDo.Installer/Steps/StartServiceStep.cs // used in Update+Repair
.gitea/workflows/release.yml
```
### Files to remove
```
src/ClaudeDo.Installer/Steps/PublishAppStep.cs
src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
```
### Files to update
- `Core/InstallerService.cs` — mode-aware step list
- `Core/InstallContext.cs` — add `Version`, `Mode`, `IsFirstInstall` fields
- `Pages/WelcomePage/*` — content + buttons depend on mode
- `Views/WizardViewModel.cs` — route pages based on mode
- `Core/PageResolver.cs` — register new/renamed pages
- `ClaudeDo.Installer.csproj` — add `PublishSingleFile`, `SelfContained`
properties (only active when published)
## Failure Modes & Recovery
| Failure | Behavior |
|---------------------------------------|-------------------------------------------------------|
| No network / Gitea unreachable | Step fails with clear message + retry button |
| API returns no releases yet | "No release available — publish a tag first" |
| Checksum mismatch | Step fails, temp files deleted, user prompted retry |
| Zip extraction fails mid-way (update) | InstallDir is left partially empty — user re-runs |
| Service won't stop | Fail before extract; nothing destructive has happened |
| User cancels mid-download | Temp dir cleaned up; install state unchanged |
For safety, the `DownloadAndExtractStep` always downloads + verifies
**before** it deletes the old binaries. An aborted download cannot leave
an install in a half-deleted state.
## Security
- All downloads over HTTPS from a pinned host (`git.kuns.dev`).
- SHA256 verification before extract (protects against partial downloads and
tampered caches on the wire; not a substitute for code signing).
- No tokens shipped in the installer — repo is public.
- Worker service runs under the same account as today (no change).
## Decisions to Revisit
1. **Release notes content.** Auto-generated `git log` summary vs manual
notes in the tag message vs empty. Start empty; revisit when there are
enough releases to care.
2. **Signed installer.** Out of scope for v1. Users will see a SmartScreen
warning the first time. Note this in the README.
3. **Installer distribution page.** A simple `README.md` badge or a pinned
"Latest release" link on the Gitea repo home is enough for v1.
## Success Criteria
- On a fresh Windows VM with **no source checkout, no .NET runtime, and no
.NET SDK**:
1. Download `ClaudeDo.Installer-<ver>.exe`.
2. Run it.
3. Complete the wizard.
4. ClaudeDo App launches, Worker service is running, a task can be created
and picked up.
- Running the same installer a second time, with no new release published,
opens directly in the Config view after a quick update check.
- Publishing a new tag, then running the installer on the existing install,
offers the update; accepting performs it without touching `~/.todo-app/todo.db`
or the config JSONs.
- Uninstall leaves no trace: `{InstallDir}` gone, `~/.todo-app` gone, service
unregistered, shortcuts removed.
- The entire release pipeline runs on `git.kuns.dev` with no manual steps
beyond `git tag vX.Y.Z && git push --tags`.

View File

@@ -0,0 +1,15 @@
<Application x:Class="ClaudeDo.Installer.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:core="clr-namespace:ClaudeDo.Installer.Core">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Theme/DarkTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
<core:NullToCollapsedConverter x:Key="NullToCollapsedConverter"/>
<core:StepActiveConverter x:Key="StepActiveConverter"/>
<BooleanToVisibilityConverter x:Key="BoolToVisConverter"/>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,131 @@
using System.Net.Http;
using System.Reflection;
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Pages.InstallPage;
using ClaudeDo.Installer.Pages.PathsPage;
using ClaudeDo.Installer.Pages.ServicePage;
using ClaudeDo.Installer.Pages.UiSettingsPage;
using ClaudeDo.Installer.Pages.WelcomePage;
using ClaudeDo.Installer.Steps;
using ClaudeDo.Installer.Views;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Installer;
public partial class App : Application
{
private ServiceProvider? _services;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_services = BuildServices();
var context = _services.GetRequiredService<InstallContext>();
context.InstallerVersion = GetInstallerVersion();
// Default install dir for detection — on upgrade we stay where we were.
var detector = _services.GetRequiredService<InstallModeDetector>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// Read manifest up front so we can fall back to Config if the API times out
// on an existing install. If the API is slow, we do NOT want to drop an
// already-installed user into FreshInstall — that would risk overwriting them.
var existingManifest = InstallManifestStore.TryRead(context.InstallDirectory);
DetectedState state;
try
{
state = await detector.DetectAsync(context.InstallDirectory, cts.Token);
}
catch (OperationCanceledException)
{
state = existingManifest is not null
? new DetectedState(InstallerMode.Config, existingManifest, null, null)
: new DetectedState(InstallerMode.FreshInstall, null, null, null);
}
context.Mode = state.Mode;
context.InstalledVersion = state.Existing?.Version;
context.LatestVersion = state.LatestVersion;
if (state.Existing is not null)
context.InstallDirectory = state.Existing.InstallDir;
Window mainWindow = state.Mode switch
{
InstallerMode.FreshInstall or InstallerMode.Update => new WizardWindow
{
DataContext = _services.GetRequiredService<WizardViewModel>()
},
InstallerMode.Config => new SettingsWindow
{
DataContext = _services.GetRequiredService<SettingsViewModel>()
},
_ => throw new InvalidOperationException($"Unknown installer mode: {state.Mode}")
};
DarkTitleBar.Apply(mainWindow);
mainWindow.Show();
}
protected override void OnExit(ExitEventArgs e)
{
_services?.Dispose();
base.OnExit(e);
}
private static string GetInstallerVersion()
{
var infoAttr = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
return infoAttr?.InformationalVersion ?? "0.0.0";
}
private static ServiceProvider BuildServices()
{
var sc = new ServiceCollection();
// Core
sc.AddSingleton<InstallContext>();
sc.AddSingleton<PageResolver>();
// HTTP + release client
sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(15) });
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallModeDetector>();
// Pages
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
sc.AddSingleton<IInstallerPage, PathsPageViewModel>();
sc.AddSingleton<IInstallerPage, ServicePageViewModel>();
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
// Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline
// can pull them out individually via GetRequiredService<T>().
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
// Stop/Start — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
// Pulled by Update flow + Repair/Uninstall.
sc.AddSingleton<StopServiceStep>();
sc.AddSingleton<StartServiceStep>();
// Runners
sc.AddSingleton<UninstallRunner>();
// ViewModels
sc.AddSingleton<WizardViewModel>();
sc.AddSingleton<SettingsViewModel>();
return sc.BuildServiceProvider();
}
}

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<!-- Debug: asInvoker so Rider/VS can debug without elevation -->
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<ApplicationManifest>app.debug.manifest</ApplicationManifest>
</PropertyGroup>
<!-- Release: requireAdministrator for service registration + shortcuts -->
<PropertyGroup Condition="'$(Configuration)' != 'Debug'">
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
<PublishTrimmed>false</PublishTrimmed>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,37 @@
using System.IO;
using System.Security.Cryptography;
namespace ClaudeDo.Installer.Core;
public static class ChecksumVerifier
{
public static string ComputeSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static bool Verify(string filePath, string expectedSha256)
{
var actual = ComputeSha256(filePath);
return string.Equals(actual, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase);
}
public static IReadOnlyDictionary<string, string> ParseChecksumsFile(string content)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in content.Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0) continue;
var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) continue;
var hashPart = parts[0].Trim();
if (hashPart.Length != 64) continue;
map[parts[1].Trim()] = hashPart;
}
return map;
}
}

View File

@@ -0,0 +1,109 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using ClaudeDo.Data;
namespace ClaudeDo.Installer.Core;
/// <summary>
/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
/// </summary>
public sealed class InstallerWorkerConfig
{
[JsonPropertyName("db_path")]
public string DbPath { get; set; } = "~/.todo-app/todo.db";
[JsonPropertyName("sandbox_root")]
public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
[JsonPropertyName("log_root")]
public string LogRoot { get; set; } = "~/.todo-app/logs";
[JsonPropertyName("worktree_root_strategy")]
public string WorktreeRootStrategy { get; set; } = "sibling";
[JsonPropertyName("central_worktree_root")]
public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
[JsonPropertyName("queue_backstop_interval_ms")]
public int QueueBackstopIntervalMs { get; set; } = 30_000;
[JsonPropertyName("signalr_port")]
public int SignalRPort { get; set; } = 47_821;
[JsonPropertyName("claude_bin")]
public string ClaudeBin { get; set; } = "claude";
private static readonly JsonSerializerOptions ReadOpts = new()
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
};
public static InstallerWorkerConfig Load()
{
var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
if (!File.Exists(path)) return new();
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerWorkerConfig>(json, ReadOpts) ?? new();
}
public void Save()
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "worker.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
}
/// <summary>
/// Mirrors ClaudeDo.Ui.AppSettings JSON shape.
/// Keep in sync with src/ClaudeDo.Ui/AppSettings.cs.
/// </summary>
public sealed class InstallerAppSettings
{
public string DbPath { get; set; } = "~/.todo-app/todo.db";
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
private static readonly JsonSerializerOptions ReadOpts = new()
{
PropertyNameCaseInsensitive = true,
};
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
};
public static InstallerAppSettings Load()
{
var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json");
if (!File.Exists(path)) return new();
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerAppSettings>(json, ReadOpts) ?? new();
}
catch
{
return new();
}
}
public void Save()
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "ui.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
}

View File

@@ -0,0 +1,29 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
namespace ClaudeDo.Installer.Core;
public static class DarkTitleBar
{
[DllImport("dwmapi.dll", PreserveSig = true)]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
public static void Apply(Window window)
{
if (window.IsLoaded)
SetDarkMode(window);
else
window.SourceInitialized += (_, _) => SetDarkMode(window);
}
private static void SetDarkMode(Window window)
{
var hwnd = new WindowInteropHelper(window).Handle;
if (hwnd == IntPtr.Zero) return;
int value = 1;
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
}
}

View File

@@ -0,0 +1,20 @@
namespace ClaudeDo.Installer.Core;
public interface IInstallStep
{
string Name { get; }
Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct);
}
public sealed class StepResult
{
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
public static StepResult Ok() => new() { Success = true };
public static StepResult Fail(string error) => new() { Success = false, ErrorMessage = error };
}
public enum StepStatus { Pending, Running, Done, Failed, Skipped }
public sealed record StepProgress(string StepName, StepStatus Status, string? Message = null);

View File

@@ -0,0 +1,16 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Core;
public interface IInstallerPage
{
string Title { get; }
string Icon { get; }
int Order { get; }
bool ShowInWizard { get; }
bool ShowInSettings { get; }
UserControl View { get; }
Task LoadAsync();
Task ApplyAsync();
bool Validate();
}

View File

@@ -0,0 +1,15 @@
namespace ClaudeDo.Installer.Core;
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
public sealed record GiteaRelease(
string TagName,
string Name,
IReadOnlyList<ReleaseAsset> Assets);
public interface IReleaseClient
{
Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct);
Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct);
}

View File

@@ -0,0 +1,35 @@
namespace ClaudeDo.Installer.Core;
public sealed class InstallContext
{
// WelcomePage / install destination
public string InstallDirectory { get; set; } = @"C:\Program Files\ClaudeDo";
// Mode + versions (set by App startup after InstallModeDetector runs)
public InstallerMode Mode { get; set; } = InstallerMode.FreshInstall;
public string? InstallerVersion { get; set; } // from this installer's assembly
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
// PathsPage
public string DbPath { get; set; } = "~/.todo-app/todo.db";
public string LogRoot { get; set; } = "~/.todo-app/logs";
public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
public string WorktreeRootStrategy { get; set; } = "sibling";
public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
// ServicePage
public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "LocalSystem";
public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000;
// UiSettingsPage
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
public string UiDbPath { get; set; } = "~/.todo-app/todo.db";
// InstallPage
public bool CreateDesktopShortcut { get; set; } = true;
}

View File

@@ -0,0 +1,48 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClaudeDo.Installer.Core;
public sealed record InstallManifest(
string Version,
string InstallDir,
string WorkerDir,
DateTimeOffset InstalledAt);
public static class InstallManifestStore
{
public const string FileName = "install.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static string ManifestPath(string installDir) => Path.Combine(installDir, FileName);
public static InstallManifest? TryRead(string installDir)
{
var path = ManifestPath(installDir);
if (!File.Exists(path)) return null;
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallManifest>(json, JsonOptions);
}
catch
{
return null;
}
}
public static void Write(string installDir, InstallManifest manifest)
{
Directory.CreateDirectory(installDir);
var json = JsonSerializer.Serialize(manifest, JsonOptions);
File.WriteAllText(ManifestPath(installDir), json);
}
}

View File

@@ -0,0 +1,48 @@
namespace ClaudeDo.Installer.Core;
public sealed record DetectedState(
InstallerMode Mode,
InstallManifest? Existing,
GiteaRelease? LatestRelease,
string? LatestVersion);
public sealed class InstallModeDetector
{
private readonly IReleaseClient _releases;
public InstallModeDetector(IReleaseClient releases)
{
_releases = releases;
}
public async Task<DetectedState> DetectAsync(string installDir, CancellationToken ct)
{
var manifest = InstallManifestStore.TryRead(installDir);
if (manifest is null)
return new DetectedState(InstallerMode.FreshInstall, null, null, null);
var release = await _releases.GetLatestReleaseAsync(ct);
if (release is null)
return new DetectedState(InstallerMode.Config, manifest, null, null);
var latestVersion = release.TagName.TrimStart('v', 'V');
if (IsNewer(latestVersion, manifest.Version))
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
}
/// <summary>
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
/// AND latest &gt; current. Semver pre-release tags like "0.2.0-beta" fail to parse and are
/// treated as "not newer" — the user drops into Config mode with no update offered.
/// This is deliberate: offering an update we can't compare is worse than silently skipping it.
/// If the project starts shipping pre-release tags, revisit this.
/// </summary>
private static bool IsNewer(string latest, string current)
{
if (!Version.TryParse(latest, out var lv)) return false;
if (!Version.TryParse(current, out var cv)) return false;
return lv > cv;
}
}

View File

@@ -0,0 +1,8 @@
namespace ClaudeDo.Installer.Core;
public enum InstallerMode
{
FreshInstall, // No install.json present -> run full wizard
Update, // install.json present, newer release available
Config, // install.json present, no update (or API unreachable)
}

View File

@@ -0,0 +1,49 @@
namespace ClaudeDo.Installer.Core;
public sealed class InstallerService
{
private readonly IEnumerable<IInstallStep> _steps;
public InstallerService(IEnumerable<IInstallStep> steps) => _steps = steps;
public async Task<IReadOnlyList<(IInstallStep Step, StepResult Result)>> ExecuteAsync(
InstallContext ctx,
IProgress<StepProgress> progress,
CancellationToken ct)
{
var results = new List<(IInstallStep, StepResult)>();
foreach (var step in _steps)
{
ct.ThrowIfCancellationRequested();
progress.Report(new StepProgress(step.Name, StepStatus.Running));
var lineProgress = new Progress<string>(msg =>
progress.Report(new StepProgress(step.Name, StepStatus.Running, msg)));
try
{
var result = await step.ExecuteAsync(ctx, lineProgress, ct);
var status = result.Success ? StepStatus.Done : StepStatus.Failed;
progress.Report(new StepProgress(step.Name, status, result.ErrorMessage));
results.Add((step, result));
if (!result.Success) break;
}
catch (OperationCanceledException)
{
progress.Report(new StepProgress(step.Name, StepStatus.Failed, "Cancelled"));
results.Add((step, StepResult.Fail("Cancelled")));
break;
}
catch (Exception ex)
{
progress.Report(new StepProgress(step.Name, StepStatus.Failed, ex.Message));
results.Add((step, StepResult.Fail(ex.Message)));
break;
}
}
return results;
}
}

View File

@@ -0,0 +1,14 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace ClaudeDo.Installer.Core;
public sealed class NullToCollapsedConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is null or "" ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,17 @@
namespace ClaudeDo.Installer.Core;
public sealed class PageResolver
{
private readonly IReadOnlyList<IInstallerPage> _allPages;
public PageResolver(IEnumerable<IInstallerPage> pages)
{
_allPages = pages.OrderBy(p => p.Order).ToList();
}
public IReadOnlyList<IInstallerPage> WizardPages =>
_allPages.Where(p => p.ShowInWizard).ToList();
public IReadOnlyList<IInstallerPage> SettingsPages =>
_allPages.Where(p => p.ShowInSettings).ToList();
}

View File

@@ -0,0 +1,60 @@
using System.Diagnostics;
using System.IO;
using System.Text;
namespace ClaudeDo.Installer.Core;
public static class ProcessRunner
{
public static async Task<(int ExitCode, string Output)> RunAsync(
string fileName,
string arguments,
string? workingDirectory,
IProgress<string>? progress,
CancellationToken ct)
{
var output = new StringBuilder();
var outputLock = new object();
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
if (!process.Start())
return (-1, "Failed to start process");
var stdoutTask = ReadStreamAsync(process.StandardOutput, output, outputLock, progress);
var stderrTask = ReadStreamAsync(process.StandardError, output, outputLock, progress);
using var reg = ct.Register(() =>
{
try { process.Kill(entireProcessTree: true); } catch { }
});
await Task.WhenAll(stdoutTask, stderrTask);
await process.WaitForExitAsync(ct);
return (process.ExitCode, output.ToString());
}
private static async Task ReadStreamAsync(
StreamReader reader,
StringBuilder output,
object outputLock,
IProgress<string>? progress)
{
while (await reader.ReadLineAsync() is { } line)
{
lock (outputLock) { output.AppendLine(line); }
progress?.Report(line);
}
}
}

View File

@@ -0,0 +1,85 @@
using System.IO;
using System.Net.Http;
using System.Text.Json;
namespace ClaudeDo.Installer.Core;
public sealed class ReleaseClient : IReleaseClient
{
public const string DefaultApiBase = "https://git.kuns.dev/api/v1/repos/releases/ClaudeDo";
private readonly HttpClient _http;
private readonly string _apiBase;
public ReleaseClient(HttpClient http, string apiBase = DefaultApiBase)
{
_http = http;
_apiBase = apiBase.TrimEnd('/');
}
public async Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
{
try
{
using var response = await _http.GetAsync($"{_apiBase}/releases/latest", ct);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(ct);
return ParseRelease(json);
}
catch (HttpRequestException) { return null; }
catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return null; }
}
public async Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
{
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await using var input = await response.Content.ReadAsStreamAsync(ct);
await using var output = File.Create(destPath);
var buffer = new byte[81920];
long total = 0;
int read;
while ((read = await input.ReadAsync(buffer, ct)) > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), ct);
total += read;
progress.Report(total);
}
}
private static GiteaRelease? ParseRelease(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("tag_name", out var tagEl)) return null;
var tag = tagEl.GetString() ?? "";
var name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
var assets = new List<ReleaseAsset>();
if (root.TryGetProperty("assets", out var arr) && arr.ValueKind == JsonValueKind.Array)
{
foreach (var item in arr.EnumerateArray())
{
if (!item.TryGetProperty("name", out var nameField)) continue;
if (!item.TryGetProperty("browser_download_url", out var urlField)) continue;
var aName = nameField.GetString() ?? "";
var aUrl = urlField.GetString() ?? "";
var aSize = item.TryGetProperty("size", out var s) ? s.GetInt64() : 0L;
assets.Add(new ReleaseAsset(aName, aUrl, aSize));
}
}
return new GiteaRelease(tag, name, assets);
}
catch (JsonException)
{
return null;
}
}
}

View File

@@ -0,0 +1,37 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace ClaudeDo.Installer.Core;
/// <summary>
/// Multi-value converter: compares the page's index with the current page index
/// to determine step indicator styling.
/// </summary>
public sealed class StepActiveConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length < 2 ||
values[0] is not IInstallerPage page ||
values[1] is not IInstallerPage currentPage)
return DependencyProperty.UnsetValue;
var isActive = ReferenceEquals(page, currentPage);
var key = parameter?.ToString() switch
{
"Background" => isActive ? "AccentBrush" : "WindowBgBrush",
"Foreground" => isActive ? "TextPrimaryBrush" : "TextMutedBrush",
"BorderBrush" => isActive ? "AccentBrush" : "BorderSubtleBrush",
_ => null
};
if (key is null) return DependencyProperty.UnsetValue;
return Application.Current.Resources[key] as SolidColorBrush ?? DependencyProperty.UnsetValue;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,127 @@
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Installer.Steps;
namespace ClaudeDo.Installer.Core;
public sealed class UninstallRunner
{
private readonly InstallContext _context;
private readonly StopServiceStep _stopService;
public UninstallRunner(InstallContext context, StopServiceStep stopService)
{
_context = context;
_stopService = stopService;
}
public async Task<StepResult> RunAsync(IProgress<string> progress, CancellationToken ct)
{
// 1) Validate install dir up front — refuse obviously unsafe paths.
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
if (!IsSafeInstallDir(_context.InstallDirectory, out var safeError))
return StepResult.Fail($"Refusing to uninstall: {safeError}");
// 2) Stop service. If stop fails we MUST abort — deleting a service whose
// process is still running leaves orphan locked binaries under the install dir
// which Directory.Delete will silently skip.
progress.Report("Stopping worker service...");
var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
if (!stopResult.Success)
return StepResult.Fail(
$"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " +
"Kill the worker manually and re-run uninstall.");
// 3) Unregister service.
progress.Report("Unregistering service...");
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
progress.Report("Removing shortcuts...");
TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
"ClaudeDo.lnk"));
TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs", "ClaudeDo.lnk"));
// 5) Delete install directory. Track success so we can report partial state.
var failures = new List<string>();
if (Directory.Exists(_context.InstallDirectory))
{
progress.Report($"Deleting {_context.InstallDirectory}...");
if (!TryDeleteDir(_context.InstallDirectory, out var err))
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
}
// 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal.
var appData = Paths.AppDataRoot();
if (Directory.Exists(appData))
{
progress.Report($"Deleting {appData}...");
if (!TryDeleteDir(appData, out var err))
failures.Add($"app data ({appData}): {err}");
}
if (failures.Count > 0)
{
return StepResult.Fail(
"Uninstall partially succeeded — the following could not be removed:\n " +
string.Join("\n ", failures));
}
progress.Report("Uninstall complete.");
return StepResult.Ok();
}
/// <summary>
/// Guards against catastrophic recursive-delete paths. The install dir must be
/// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").
/// </summary>
private static bool IsSafeInstallDir(string path, out string reason)
{
if (string.IsNullOrWhiteSpace(path))
{
reason = "install directory is empty";
return false;
}
string full;
try { full = Path.GetFullPath(path); }
catch (Exception ex)
{
reason = $"install directory is not a valid path: {ex.Message}";
return false;
}
var name = Path.GetFileName(full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (string.IsNullOrEmpty(name))
{
reason = $"install directory resolves to a drive root ({full})";
return false;
}
reason = "";
return true;
}
private static void TryDeleteFile(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort — single shortcut */ }
}
private static bool TryDeleteDir(string path, out string error)
{
try
{
Directory.Delete(path, recursive: true);
error = "";
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}

View File

@@ -0,0 +1,101 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.InstallPage.InstallPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.InstallPage"
d:DataContext="{d:DesignInstance local:InstallPageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="Installation" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Click Install to build and deploy ClaudeDo."
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap"/>
</StackPanel>
<!-- Step List -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Steps}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type local:StepViewModel}">
<Border Margin="0,0,0,6" Padding="10,8"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1" CornerRadius="4">
<StackPanel>
<!-- Step header -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Status indicator -->
<Ellipse Grid.Column="0" Width="10" Height="10" Margin="0,0,10,0"
VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{StaticResource StatusGrayBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="Running">
<Setter Property="Fill" Value="{StaticResource StatusOrangeBrush}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Done">
<Setter Property="Fill" Value="{StaticResource StatusGreenBrush}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Failed">
<Setter Property="Fill" Value="{StaticResource StatusRedBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Grid.Column="1" Text="{Binding Name}" FontSize="13"
VerticalAlignment="Center"/>
</Grid>
<!-- Messages (expandable) -->
<ItemsControl ItemsSource="{Binding Messages}" Margin="20,4,0,0"
Visibility="{Binding IsExpanded, Converter={StaticResource BoolToVisConverter}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="11" FontFamily="Consolas"
Foreground="{StaticResource TextDimBrush}"
TextWrapping="Wrap"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Progress Bar -->
<ProgressBar Grid.Row="2" Value="{Binding OverallProgress}" Maximum="100"
Margin="0,12,0,0"
Visibility="{Binding IsInstalling, Converter={StaticResource BoolToVisConverter}}"/>
<!-- Action buttons -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Cancel" Command="{Binding CancelInstallCommand}"
Visibility="{Binding IsInstalling, Converter={StaticResource BoolToVisConverter}}"
Margin="0,0,8,0"/>
<Button Content="Launch ClaudeDo" Command="{Binding LaunchAppCommand}"
Style="{StaticResource AccentButton}"
Visibility="{Binding IsComplete, Converter={StaticResource BoolToVisConverter}}"/>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class InstallPageView : UserControl
{
public InstallPageView() => InitializeComponent();
}

View File

@@ -0,0 +1,148 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class InstallPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private readonly IServiceProvider _serviceProvider;
private InstallPageView? _view;
private CancellationTokenSource? _cts;
public string Title => "Install";
public string Icon => "\uE896";
public int Order => 99;
public bool ShowInWizard => true;
public bool ShowInSettings => false;
public UserControl View => _view ??= new InstallPageView { DataContext = this };
public ObservableCollection<StepViewModel> Steps { get; } = [];
[ObservableProperty] private bool _isInstalling;
[ObservableProperty] private bool _isComplete;
[ObservableProperty] private bool _hasErrors;
[ObservableProperty] private double _overallProgress;
public InstallPageViewModel(InstallContext context, IServiceProvider serviceProvider)
{
_context = context;
_serviceProvider = serviceProvider;
}
public Task LoadAsync()
{
Steps.Clear();
if (_context.Mode == InstallerMode.Update)
{
Steps.Add(new StepViewModel("Stop Worker Service"));
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Start Worker Service"));
Steps.Add(new StepViewModel("Write Install Manifest"));
}
else
{
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Write Configuration"));
Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Windows Service"));
Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Write Install Manifest"));
}
return Task.CompletedTask;
}
public Task ApplyAsync() => RunInstallAsync();
public bool Validate() => true;
[RelayCommand]
private async Task RunInstallAsync()
{
if (IsInstalling) return;
IsInstalling = true;
IsComplete = false;
HasErrors = false;
OverallProgress = 0;
_cts = new CancellationTokenSource();
var progress = new Progress<StepProgress>(p =>
{
var step = Steps.FirstOrDefault(s => s.Name == p.StepName);
if (step is null) return;
step.Status = p.Status;
if (p.Message is not null)
step.Messages.Add(p.Message);
if (p.Status is StepStatus.Running && !step.IsExpanded)
step.IsExpanded = true;
if (p.Status is StepStatus.Done or StepStatus.Failed)
{
var completed = Steps.Count(s => s.Status is StepStatus.Done or StepStatus.Failed);
OverallProgress = (double)completed / Steps.Count * 100;
}
});
try
{
IEnumerable<IInstallStep> steps;
if (_context.Mode == InstallerMode.Update)
{
steps = new IInstallStep[]
{
_serviceProvider.GetRequiredService<StopServiceStep>(),
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
_serviceProvider.GetRequiredService<StartServiceStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
};
}
else
{
steps = _serviceProvider.GetServices<IInstallStep>();
}
var runner = new InstallerService(steps);
var results = await runner.ExecuteAsync(_context, progress, _cts.Token);
HasErrors = results.Any(r => !r.Result.Success);
}
catch (OperationCanceledException)
{
HasErrors = true;
}
finally
{
IsInstalling = false;
IsComplete = true;
_cts.Dispose();
_cts = null;
}
}
[RelayCommand]
private void CancelInstall()
{
_cts?.Cancel();
}
[RelayCommand]
private void LaunchApp()
{
var appExe = System.IO.Path.Combine(_context.InstallDirectory, "app", "ClaudeDo.App.exe");
if (System.IO.File.Exists(appExe))
{
Process.Start(new ProcessStartInfo(appExe) { UseShellExecute = true });
Application.Current.Shutdown();
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.ObjectModel;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class StepViewModel : ObservableObject
{
public string Name { get; }
[ObservableProperty] private StepStatus _status = StepStatus.Pending;
[ObservableProperty] private bool _isExpanded;
public ObservableCollection<string> Messages { get; } = [];
public StepViewModel(string name) => Name = name;
}

View File

@@ -0,0 +1,41 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.PathsPage.PathsPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.PathsPage"
d:DataContext="{d:DesignInstance local:PathsPageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="Data Paths" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure where ClaudeDo stores its data."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
<Label Content="Database Path"/>
<TextBox Text="{Binding DbPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Log Directory"/>
<TextBox Text="{Binding LogRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Sandbox Root"/>
<TextBox Text="{Binding SandboxRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Worktree Strategy"/>
<ComboBox SelectedItem="{Binding WorktreeRootStrategy}" Margin="0,0,0,12">
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">sibling</sys:String>
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">central</sys:String>
</ComboBox>
<StackPanel Visibility="{Binding IsCentralVisible, Converter={StaticResource BoolToVisConverter}}">
<Label Content="Central Worktree Root"/>
<TextBox Text="{Binding CentralWorktreeRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
</StackPanel>
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.PathsPage;
public partial class PathsPageView : UserControl
{
public PathsPageView() => InitializeComponent();
}

View File

@@ -0,0 +1,74 @@
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.PathsPage;
public partial class PathsPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private PathsPageView? _view;
public string Title => "Paths";
public string Icon => "\uE8B7";
public int Order => 1;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new PathsPageView { DataContext = this };
[ObservableProperty] private string _dbPath = "~/.todo-app/todo.db";
[ObservableProperty] private string _logRoot = "~/.todo-app/logs";
[ObservableProperty] private string _sandboxRoot = "~/.todo-app/sandbox";
[ObservableProperty] private string _worktreeRootStrategy = "sibling";
[ObservableProperty] private string _centralWorktreeRoot = "~/.todo-app/worktrees";
[ObservableProperty] private string? _validationError;
public bool IsCentralVisible => WorktreeRootStrategy == "central";
public PathsPageViewModel(InstallContext context) => _context = context;
partial void OnWorktreeRootStrategyChanged(string value) =>
OnPropertyChanged(nameof(IsCentralVisible));
public Task LoadAsync()
{
var cfg = InstallerWorkerConfig.Load();
DbPath = cfg.DbPath;
LogRoot = cfg.LogRoot;
SandboxRoot = cfg.SandboxRoot;
WorktreeRootStrategy = cfg.WorktreeRootStrategy;
CentralWorktreeRoot = cfg.CentralWorktreeRoot;
return Task.CompletedTask;
}
public Task ApplyAsync()
{
_context.DbPath = DbPath;
_context.UiDbPath = DbPath;
_context.LogRoot = LogRoot;
_context.SandboxRoot = SandboxRoot;
_context.WorktreeRootStrategy = WorktreeRootStrategy;
_context.CentralWorktreeRoot = CentralWorktreeRoot;
return Task.CompletedTask;
}
public bool Validate()
{
if (string.IsNullOrWhiteSpace(DbPath) ||
string.IsNullOrWhiteSpace(LogRoot) ||
string.IsNullOrWhiteSpace(SandboxRoot))
{
ValidationError = "All path fields are required.";
return false;
}
if (WorktreeRootStrategy == "central" && string.IsNullOrWhiteSpace(CentralWorktreeRoot))
{
ValidationError = "Central worktree root is required when using central strategy.";
return false;
}
ValidationError = null;
return true;
}
}

View File

@@ -0,0 +1,56 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.ServicePage.ServicePageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.ServicePage"
d:DataContext="{d:DesignInstance local:ServicePageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="Worker Service" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo Worker background service."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
<Label Content="SignalR Port"/>
<TextBox Text="{Binding SignalRPort, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Queue Backstop Interval (ms)"/>
<TextBox Text="{Binding QueueBackstopIntervalMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Claude CLI Path"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding ClaudeBin, UpdateSourceTrigger=PropertyChanged}"/>
<Button Grid.Column="1" Content="Browse..." Command="{Binding BrowseClaudeCommand}"
Margin="8,0,0,0"/>
</Grid>
<Separator Margin="0,4,0,12"/>
<Label Content="Service Account"/>
<StackPanel Margin="0,0,0,12">
<RadioButton Content="Local System (recommended)"
IsChecked="{Binding IsLocalSystem}" Margin="0,0,0,4"/>
<RadioButton Content="Current User"
IsChecked="{Binding IsCurrentUser}"/>
<TextBlock Text="Running as current user requires 'Log on as a service' privilege."
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="20,2,0,0"
TextWrapping="Wrap"/>
</StackPanel>
<CheckBox Content="Start service automatically" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
<Label Content="Restart Delay (ms)"/>
<TextBox Text="{Binding RestartDelayMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.ServicePage;
public partial class ServicePageView : UserControl
{
public ServicePageView() => InitializeComponent();
}

View File

@@ -0,0 +1,88 @@
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Pages.ServicePage;
public partial class ServicePageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private ServicePageView? _view;
public string Title => "Service";
public string Icon => "\uE912";
public int Order => 2;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new ServicePageView { DataContext = this };
[ObservableProperty] private int _signalRPort = 47_821;
[ObservableProperty] private int _queueBackstopIntervalMs = 30_000;
[ObservableProperty] private string _claudeBin = "claude";
[ObservableProperty] private bool _isLocalSystem = true;
[ObservableProperty] private bool _isCurrentUser;
[ObservableProperty] private bool _autoStart = true;
[ObservableProperty] private int _restartDelayMs = 5000;
[ObservableProperty] private string? _validationError;
public ServicePageViewModel(InstallContext context) => _context = context;
public Task LoadAsync()
{
var cfg = InstallerWorkerConfig.Load();
SignalRPort = cfg.SignalRPort;
QueueBackstopIntervalMs = cfg.QueueBackstopIntervalMs;
ClaudeBin = cfg.ClaudeBin;
return Task.CompletedTask;
}
public Task ApplyAsync()
{
_context.SignalRPort = SignalRPort;
_context.QueueBackstopIntervalMs = QueueBackstopIntervalMs;
_context.ClaudeBin = ClaudeBin;
_context.ServiceAccount = IsCurrentUser ? "CurrentUser" : "LocalSystem";
_context.AutoStart = AutoStart;
_context.RestartDelayMs = RestartDelayMs;
_context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub";
return Task.CompletedTask;
}
public bool Validate()
{
if (SignalRPort < 1024 || SignalRPort > 65535)
{
ValidationError = "Port must be between 1024 and 65535.";
return false;
}
if (QueueBackstopIntervalMs <= 0)
{
ValidationError = "Queue backstop interval must be greater than 0.";
return false;
}
if (string.IsNullOrWhiteSpace(ClaudeBin))
{
ValidationError = "Claude CLI path is required.";
return false;
}
ValidationError = null;
return true;
}
[RelayCommand]
private void BrowseClaude()
{
var dialog = new OpenFileDialog
{
Title = "Select Claude CLI executable",
Filter = "Executables (*.exe)|*.exe|All files (*.*)|*.*",
};
if (dialog.ShowDialog() == true)
ClaudeBin = dialog.FileName;
}
}

View File

@@ -0,0 +1,36 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.UiSettingsPage.UiSettingsPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.UiSettingsPage"
d:DataContext="{d:DesignInstance local:UiSettingsPageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="UI Settings" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo desktop UI connection settings."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
<CheckBox Content="Sync with service settings" IsChecked="{Binding IsSynced}" Margin="0,0,0,16"/>
<Label Content="SignalR URL"/>
<TextBox Text="{Binding SignalRUrl, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="{Binding IsSynced}" Margin="0,0,0,12"/>
<Label Content="Database Path"/>
<TextBox Text="{Binding UiDbPath, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="{Binding IsSynced}" Margin="0,0,0,12"/>
<TextBlock Text="When synced, these values are derived from the Service and Paths pages."
Foreground="{StaticResource TextDimBrush}" FontSize="11" TextWrapping="Wrap"
Visibility="{Binding IsSynced, Converter={StaticResource BoolToVisConverter}}"
Margin="0,0,0,12"/>
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.UiSettingsPage;
public partial class UiSettingsPageView : UserControl
{
public UiSettingsPageView() => InitializeComponent();
}

View File

@@ -0,0 +1,83 @@
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.UiSettingsPage;
public partial class UiSettingsPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private UiSettingsPageView? _view;
public string Title => "UI Settings";
public string Icon => "\uE771";
public int Order => 3;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new UiSettingsPageView { DataContext = this };
[ObservableProperty] private string _signalRUrl = "http://127.0.0.1:47821/hub";
[ObservableProperty] private string _uiDbPath = "~/.todo-app/todo.db";
[ObservableProperty] private bool _isSynced = true;
[ObservableProperty] private string? _validationError;
public UiSettingsPageViewModel(InstallContext context) => _context = context;
partial void OnIsSyncedChanged(bool value)
{
if (value) SyncFromContext();
}
private void SyncFromContext()
{
SignalRUrl = $"http://127.0.0.1:{_context.SignalRPort}/hub";
UiDbPath = _context.DbPath;
}
public Task LoadAsync()
{
if (IsSynced)
{
SyncFromContext();
}
else
{
var cfg = InstallerAppSettings.Load();
SignalRUrl = cfg.SignalRUrl;
UiDbPath = cfg.DbPath;
}
return Task.CompletedTask;
}
public Task ApplyAsync()
{
if (IsSynced) SyncFromContext();
_context.SignalRUrl = SignalRUrl;
_context.UiDbPath = UiDbPath;
return Task.CompletedTask;
}
public bool Validate()
{
if (string.IsNullOrWhiteSpace(SignalRUrl))
{
ValidationError = "SignalR URL is required.";
return false;
}
if (string.IsNullOrWhiteSpace(UiDbPath))
{
ValidationError = "Database path is required.";
return false;
}
if (!Uri.TryCreate(SignalRUrl, UriKind.Absolute, out _))
{
ValidationError = "SignalR URL must be a valid URL.";
return false;
}
ValidationError = null;
return true;
}
}

View File

@@ -0,0 +1,37 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.WelcomePage.WelcomePageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.WelcomePage"
d:DataContext="{d:DesignInstance local:WelcomePageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="{Binding Heading}" FontSize="20" FontWeight="SemiBold" Margin="0,0,0,6"/>
<TextBlock Text="{Binding Subheading}" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,24"/>
<Label Content="Install Directory"/>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding InstallDirectory, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding InstallDirEditable}"/>
<Button Grid.Column="1"
Content="Browse..."
Margin="8,0,0,0"
Command="{Binding BrowseInstallCommand}"
IsEnabled="{Binding InstallDirEditable}"/>
</Grid>
<TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.WelcomePage;
public partial class WelcomePageView : UserControl
{
public WelcomePageView() => InitializeComponent();
}

View File

@@ -0,0 +1,87 @@
using System.IO;
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Pages.WelcomePage;
public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private WelcomePageView? _view;
public string Title => "Welcome";
public string Icon => "\uE80F";
public int Order => 0;
public bool ShowInWizard => true;
public bool ShowInSettings => false;
public UserControl View => _view ??= new WelcomePageView { DataContext = this };
[ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
[ObservableProperty] private string? _installError;
[ObservableProperty] private string _heading = "Install ClaudeDo";
[ObservableProperty] private string _subheading = "Set the installation directory and continue.";
[ObservableProperty] private bool _installDirEditable = true;
public WelcomePageViewModel(InstallContext context)
{
_context = context;
}
public Task LoadAsync()
{
InstallDirectory = string.IsNullOrEmpty(_context.InstallDirectory)
? @"C:\Program Files\ClaudeDo"
: _context.InstallDirectory;
switch (_context.Mode)
{
case InstallerMode.FreshInstall:
Heading = "Install ClaudeDo";
Subheading = "Choose where to install ClaudeDo, then click Next.";
InstallDirEditable = true;
break;
case InstallerMode.Update:
Heading = $"Update ClaudeDo {_context.InstalledVersion ?? "?"} -> {_context.LatestVersion ?? "?"}";
Subheading = "Your tasks, config, and database will be preserved. Click Next to continue.";
InstallDirEditable = false; // stay where we were installed
break;
default:
// Config and any future modes should never reach the wizard; guard loudly if they do.
throw new InvalidOperationException(
$"WelcomePage is not valid for installer mode {_context.Mode}");
}
return Task.CompletedTask;
}
public Task ApplyAsync()
{
_context.InstallDirectory = InstallDirectory;
return Task.CompletedTask;
}
public bool Validate()
{
if (string.IsNullOrWhiteSpace(InstallDirectory))
{
InstallError = "Install directory is required";
return false;
}
InstallError = null;
return true;
}
[RelayCommand]
private void BrowseInstall()
{
if (!InstallDirEditable) return;
var dialog = new OpenFolderDialog { Title = "Select installation directory" };
if (dialog.ShowDialog() == true)
InstallDirectory = dialog.FolderName;
}
}

View File

@@ -0,0 +1,91 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class CreateShortcutsStep : IInstallStep
{
public string Name => "Create Shortcuts";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
try
{
var appExe = Path.Combine(ctx.InstallDirectory, "app", "ClaudeDo.App.exe");
var workingDir = Path.Combine(ctx.InstallDirectory, "app");
// Start Menu shortcut
var startMenuDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs");
Directory.CreateDirectory(startMenuDir);
var startMenuPath = Path.Combine(startMenuDir, "ClaudeDo.lnk");
CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
progress.Report($"Created Start Menu shortcut: {startMenuPath}");
// Desktop shortcut (optional)
if (ctx.CreateDesktopShortcut)
{
var desktopPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
"ClaudeDo.lnk");
CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
progress.Report($"Created Desktop shortcut: {desktopPath}");
}
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
private static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
{
var link = (IShellLink)new ShellLink();
link.SetPath(targetPath);
link.SetWorkingDirectory(workingDir);
link.SetDescription(description);
link.SetIconLocation(targetPath, 0);
var file = (IPersistFile)link;
file.Save(shortcutPath, false);
}
#region COM Interop for IShellLink
[ComImport]
[Guid("00021401-0000-0000-C000-000000000046")]
private class ShellLink { }
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214F9-0000-0000-C000-000000000046")]
private interface IShellLink
{
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
void GetIDList(out IntPtr ppidl);
void SetIDList(IntPtr pidl);
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
void GetHotkey(out short pwHotkey);
void SetHotkey(short wHotkey);
void GetShowCmd(out int piShowCmd);
void SetShowCmd(int iShowCmd);
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
void Resolve(IntPtr hwnd, int fFlags);
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
}
#endregion
}

View File

@@ -0,0 +1,91 @@
using System.IO;
using System.IO.Compression;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class DownloadAndExtractStep : IInstallStep
{
private readonly IReleaseClient _releases;
public DownloadAndExtractStep(IReleaseClient releases)
{
_releases = releases;
}
public string Name => "Download and Extract";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(ctx.InstallDirectory))
return StepResult.Fail("Install directory is not set.");
progress.Report("Fetching latest release metadata...");
var release = await _releases.GetLatestReleaseAsync(ct);
if (release is null)
return StepResult.Fail("Could not reach the release server. Check your network connection and try again.");
var zipAsset = release.Assets.FirstOrDefault(a =>
a.Name.StartsWith("ClaudeDo-", StringComparison.OrdinalIgnoreCase) &&
a.Name.EndsWith("-win-x64.zip", StringComparison.OrdinalIgnoreCase));
var checksumAsset = release.Assets.FirstOrDefault(a =>
a.Name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase));
if (zipAsset is null)
return StepResult.Fail("Release zip asset not found in release metadata.");
if (checksumAsset is null)
return StepResult.Fail("checksums.txt not found in release metadata.");
var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(scratchDir);
try
{
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)...");
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")),
ct);
progress.Report("Downloading checksums...");
await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
new Progress<long>(_ => { }), ct);
progress.Report("Verifying checksum...");
var map = ChecksumVerifier.ParseChecksumsFile(await File.ReadAllTextAsync(checksumPath, ct));
if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");
if (!ChecksumVerifier.Verify(zipPath, expectedHash))
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
// Only after verification do we touch the install directory.
progress.Report("Clearing previous app/worker binaries...");
var appDest = Path.Combine(ctx.InstallDirectory, "app");
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
progress.Report("Extracting...");
Directory.CreateDirectory(ctx.InstallDirectory);
try
{
ZipFile.ExtractToDirectory(zipPath, ctx.InstallDirectory, overwriteFiles: true);
}
catch (Exception ex)
{
return StepResult.Fail(
$"Extraction failed after old binaries were removed: {ex.Message}. " +
"Your install directory may be incomplete. Re-run the installer to retry.");
}
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
return StepResult.Ok();
}
finally
{
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
}
}
}

View File

@@ -0,0 +1,28 @@
using ClaudeDo.Data;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class InitDatabaseStep : IInstallStep
{
public string Name => "Initialize Database";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
try
{
var expandedPath = Paths.Expand(ctx.DbPath);
progress.Report($"Initializing database at {expandedPath}");
var factory = new SqliteConnectionFactory(expandedPath);
SchemaInitializer.Apply(factory);
progress.Report("Schema applied successfully");
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
}

View File

@@ -0,0 +1,68 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterServiceStep : IInstallStep
{
private const string ServiceName = "ClaudeDoWorker";
public string Name => "Register Windows Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// Stop existing service (ignore errors — may not exist)
progress.Report("Stopping existing service (if any)...");
await RunSc($"stop {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Delete existing service (ignore errors)
progress.Report("Removing existing service registration (if any)...");
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Create service
var startType = ctx.AutoStart ? "auto" : "demand";
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
if (ctx.ServiceAccount == "CurrentUser")
{
var username = Environment.UserName;
createArgs += $" obj= \".\\{username}\"";
}
progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
if (exitCode != 0)
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
// Configure restart policy
var delay = ctx.RestartDelayMs;
var failureArgs = $"failure {ServiceName} reset= 86400 actions= restart/{delay}/restart/{delay}/restart/{delay}";
progress.Report("Configuring restart policy...");
var (failExit, failOutput) = await RunSc(failureArgs, ctx, progress, ct);
if (failExit != 0)
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
// Start service if auto-start
if (ctx.AutoStart)
{
progress.Report("Starting service...");
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
if (startExit != 0)
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
}
return StepResult.Ok();
}
private static async Task<(int ExitCode, string Output)> RunSc(
string arguments, InstallContext ctx, IProgress<string> progress,
CancellationToken ct, bool ignoreErrors = false)
{
var result = await ProcessRunner.RunAsync("sc.exe", arguments, null, progress, ct);
return result;
}
}

View File

@@ -0,0 +1,27 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartServiceStep : IInstallStep
{
private const string ServiceName = StopServiceStep.ServiceName;
public string Name => "Start Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Starting {ServiceName}...");
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
if (exit == 0) return StepResult.Ok();
// Exit 1056 = ERROR_SERVICE_ALREADY_RUNNING — that's fine too.
if (exit == 1056)
{
progress.Report("Service was already running.");
return StepResult.Ok();
}
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
}
}

View File

@@ -0,0 +1,48 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopServiceStep : IInstallStep
{
public const string ServiceName = "ClaudeDoWorker";
public string Name => "Stop Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Stopping {ServiceName} (if running)...");
// sc.exe query -> returns non-zero if the service does not exist; that's fine.
var (queryExit, queryOutput) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (queryExit != 0)
{
progress.Report("Service is not registered — nothing to stop.");
return StepResult.Ok();
}
if (queryOutput.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service is already stopped.");
return StepResult.Ok();
}
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
if (stopExit != 0)
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
// Poll until stopped or timeout (up to 30s).
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(1000, ct);
var (e, o) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (e != 0 || o.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service stopped.");
return StepResult.Ok();
}
}
return StepResult.Fail("Service did not stop within 30 seconds.");
}
}

View File

@@ -0,0 +1,42 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class WriteConfigStep : IInstallStep
{
public string Name => "Write Configuration";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
try
{
var workerCfg = new InstallerWorkerConfig
{
DbPath = ctx.DbPath,
SandboxRoot = ctx.SandboxRoot,
LogRoot = ctx.LogRoot,
WorktreeRootStrategy = ctx.WorktreeRootStrategy,
CentralWorktreeRoot = ctx.CentralWorktreeRoot,
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
SignalRPort = ctx.SignalRPort,
ClaudeBin = ctx.ClaudeBin,
};
workerCfg.Save();
progress.Report("Written worker.config.json");
var uiCfg = new InstallerAppSettings
{
DbPath = ctx.UiDbPath,
SignalRUrl = ctx.SignalRUrl,
};
uiCfg.Save();
progress.Report("Written ui.config.json");
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
}

View File

@@ -0,0 +1,32 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class WriteInstallManifestStep : IInstallStep
{
public string Name => "Write Install Manifest";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(ctx.InstalledVersion))
return Task.FromResult(StepResult.Fail("Installed version is not set — DownloadAndExtractStep must run first."));
try
{
var manifest = new InstallManifest(
Version: ctx.InstalledVersion,
InstallDir: ctx.InstallDirectory,
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
InstalledAt: DateTimeOffset.UtcNow);
InstallManifestStore.Write(ctx.InstallDirectory, manifest);
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
}

View File

@@ -0,0 +1,280 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ═══════════════════════════════════════════════════════════
Color palette — mirrored from ClaudeDo.App App.axaml
═══════════════════════════════════════════════════════════ -->
<!-- Accent: Forest Teal -->
<Color x:Key="AccentColor">#3d9474</Color>
<Color x:Key="AccentLightColor">#6bb89e</Color>
<SolidColorBrush x:Key="AccentBrush" Color="#3d9474"/>
<SolidColorBrush x:Key="AccentLightBrush" Color="#6bb89e"/>
<SolidColorBrush x:Key="AccentSubtleBrush" Color="#1A3D9474"/>
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#263D9474"/>
<!-- Text -->
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#f1f5f9"/>
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#c8d0dc"/>
<SolidColorBrush x:Key="TextMutedBrush" Color="#8892a2"/>
<SolidColorBrush x:Key="TextDimBrush" Color="#6b7688"/>
<!-- Borders & Backgrounds -->
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#3a3f46"/>
<SolidColorBrush x:Key="WindowBgBrush" Color="#1c1e21"/>
<SolidColorBrush x:Key="IslandBgBrush" Color="#272a2e"/>
<SolidColorBrush x:Key="SidebarBgBrush" Color="#272a2e"/>
<SolidColorBrush x:Key="ContentBgBrush" Color="#272a2e"/>
<!-- Status -->
<SolidColorBrush x:Key="StatusGrayBrush" Color="#475569"/>
<SolidColorBrush x:Key="StatusOrangeBrush" Color="#e67e22"/>
<SolidColorBrush x:Key="StatusGreenBrush" Color="#3d9474"/>
<SolidColorBrush x:Key="StatusRedBrush" Color="#ef4444"/>
<!-- Selection highlights -->
<SolidColorBrush x:Key="SelectionBrush" Color="#333d9474"/>
<SolidColorBrush x:Key="SelectionHoverBrush" Color="#1A3D9474"/>
<SolidColorBrush x:Key="SelectionActiveHoverBrush" Color="#403D9474"/>
<!-- Validation -->
<SolidColorBrush x:Key="ErrorBrush" Color="#ef4444"/>
<!-- ═══════════════════════════════════════════════════════════
Global control styles
═══════════════════════════════════════════════════════════ -->
<!-- Window -->
<Style TargetType="Window">
<Setter Property="Background" Value="{StaticResource WindowBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="FontSize" Value="13"/>
</Style>
<!-- UserControl — transparent so window background shows through -->
<Style TargetType="UserControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- ContentControl — transparent container -->
<Style TargetType="ContentControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- TextBlock -->
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- Label -->
<Style TargetType="Label">
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Padding" Value="0,0,0,2"/>
</Style>
<!-- TextBox -->
<Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="CaretBrush" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="SelectionBrush" Value="{StaticResource AccentSubtleBrush}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsReadOnly" Value="True">
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- PasswordBox -->
<Style TargetType="PasswordBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="CaretBrush" Value="{StaticResource TextPrimaryBrush}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Button (default) -->
<Style TargetType="Button">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="16,6"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentSubtleBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Accent Button style -->
<Style x:Key="AccentButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="16,6"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentLightBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBox -->
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
</Style>
<!-- CheckBox -->
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="4,0,0,0"/>
</Style>
<!-- RadioButton -->
<Style TargetType="RadioButton">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="4,0,0,0"/>
</Style>
<!-- ListBox -->
<Style TargetType="ListBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<!-- ListBoxItem -->
<Style TargetType="ListBoxItem">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="Transparent"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True"/>
<Condition Property="IsMouseOver" Value="True"/>
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionActiveHoverBrush}"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ProgressBar -->
<Style TargetType="ProgressBar">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Height" Value="6"/>
</Style>
<!-- ScrollViewer -->
<Style TargetType="ScrollViewer">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- Border — default transparent -->
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
</Style>
<!-- ItemsControl -->
<Style TargetType="ItemsControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- Separator -->
<Style TargetType="Separator">
<Setter Property="Background" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="Height" Value="1"/>
<Setter Property="Margin" Value="0,8"/>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,161 @@
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Installer.Views;
public partial class SettingsViewModel : ObservableObject
{
private readonly InstallContext _context;
private readonly IReleaseClient _releases;
private readonly StopServiceStep _stopService;
private readonly StartServiceStep _startService;
private readonly DownloadAndExtractStep _downloadStep;
private readonly UninstallRunner _uninstallRunner;
public IReadOnlyList<IInstallerPage> Pages { get; }
[ObservableProperty]
private IInstallerPage? _selectedPage;
[ObservableProperty]
private string? _statusMessage;
[ObservableProperty]
private bool _isStatusError;
[ObservableProperty]
private string _versionLabel = "";
public SettingsViewModel(
PageResolver resolver,
InstallContext context,
IReleaseClient releases,
StopServiceStep stopService,
StartServiceStep startService,
DownloadAndExtractStep downloadStep,
UninstallRunner uninstallRunner)
{
Pages = resolver.SettingsPages;
_context = context;
_releases = releases;
_stopService = stopService;
_startService = startService;
_downloadStep = downloadStep;
_uninstallRunner = uninstallRunner;
_selectedPage = Pages.FirstOrDefault();
VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
_ = LoadAllAsync();
}
private async Task LoadAllAsync()
{
foreach (var page in Pages)
await page.LoadAsync();
}
[RelayCommand]
private async Task Save()
{
foreach (var page in Pages)
{
if (!page.Validate())
{
SelectedPage = page;
StatusMessage = $"Validation failed on {page.Title}. Please fix the errors.";
IsStatusError = true;
return;
}
}
foreach (var page in Pages)
await page.ApplyAsync();
var workerCfg = new InstallerWorkerConfig
{
DbPath = _context.DbPath,
SandboxRoot = _context.SandboxRoot,
LogRoot = _context.LogRoot,
WorktreeRootStrategy = _context.WorktreeRootStrategy,
CentralWorktreeRoot = _context.CentralWorktreeRoot,
QueueBackstopIntervalMs = _context.QueueBackstopIntervalMs,
SignalRPort = _context.SignalRPort,
ClaudeBin = _context.ClaudeBin,
};
workerCfg.Save();
var uiCfg = new InstallerAppSettings
{
DbPath = _context.UiDbPath,
SignalRUrl = _context.SignalRUrl,
};
uiCfg.Save();
StatusMessage = "Settings saved.";
IsStatusError = false;
}
[RelayCommand]
private async Task Repair()
{
var confirm = MessageBox.Show(
"Re-download and reinstall the ClaudeDo binaries? Your config and database are NOT affected.",
"Repair ClaudeDo",
MessageBoxButton.OKCancel,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.OK) return;
StatusMessage = "Repairing...";
IsStatusError = false;
var progress = new Progress<string>(msg => StatusMessage = msg);
var steps = new IInstallStep[] { _stopService, _downloadStep, _startService };
foreach (var step in steps)
{
var r = await step.ExecuteAsync(_context, progress, CancellationToken.None);
if (!r.Success)
{
StatusMessage = $"{step.Name} failed: {r.ErrorMessage}";
IsStatusError = true;
return;
}
}
StatusMessage = "Repair complete.";
}
[RelayCommand]
private async Task Uninstall()
{
var confirm = MessageBox.Show(
"This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
"Uninstall ClaudeDo",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (confirm != MessageBoxResult.Yes) return;
var progress = new Progress<string>(msg => StatusMessage = msg);
var r = await _uninstallRunner.RunAsync(progress, CancellationToken.None);
if (!r.Success)
{
StatusMessage = $"Uninstall failed: {r.ErrorMessage}";
IsStatusError = true;
return;
}
MessageBox.Show("ClaudeDo has been removed.", "Uninstall complete",
MessageBoxButton.OK, MessageBoxImage.Information);
Application.Current.Shutdown();
}
[RelayCommand]
private void Close() => Application.Current.Shutdown();
}

View File

@@ -0,0 +1,98 @@
<Window x:Class="ClaudeDo.Installer.Views.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Settings"
Width="720" Height="520"
MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen"
d:DataContext="{d:DesignInstance views:SettingsViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Main content area -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Sidebar -->
<Border Grid.Column="0" Background="{StaticResource SidebarBgBrush}"
Padding="8,12">
<ListBox ItemsSource="{Binding Pages}"
SelectedItem="{Binding SelectedPage}"
HorizontalContentAlignment="Stretch">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Icon}" FontSize="14" Margin="0,0,8,0"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Title}" FontSize="13"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Separator -->
<Border Grid.Column="1" Width="1" Background="{StaticResource BorderSubtleBrush}"/>
<!-- Page content -->
<Border Grid.Column="2" Padding="24,20">
<ContentControl Content="{Binding SelectedPage.View}"/>
</Border>
</Grid>
<!-- Bottom Bar -->
<Border Grid.Row="1" Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,1,0,0"
Padding="20,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Status message / version label -->
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding VersionLabel}" FontSize="11" Opacity="0.7"/>
<TextBlock Text="{Binding StatusMessage}" FontSize="12">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsStatusError}" Value="True">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
Command="{Binding UninstallCommand}"/>
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
Command="{Binding RepairCommand}"/>
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
Command="{Binding SaveCommand}"
Style="{StaticResource AccentButton}"/>
<Button Grid.Column="4" Content="Close"
Command="{Binding CloseCommand}"/>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,11 @@
using System.Windows;
namespace ClaudeDo.Installer.Views;
public partial class SettingsWindow : Window
{
public SettingsWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,85 @@
using System.Linq;
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Pages.InstallPage;
using ClaudeDo.Installer.Pages.WelcomePage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Installer.Views;
public partial class WizardViewModel : ObservableObject
{
private readonly InstallContext _context;
public IReadOnlyList<IInstallerPage> Pages { get; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanGoBack))]
[NotifyPropertyChangedFor(nameof(IsLastPage))]
[NotifyPropertyChangedFor(nameof(NextButtonText))]
[NotifyPropertyChangedFor(nameof(CurrentPage))]
private int _currentPageIndex;
public IInstallerPage CurrentPage => Pages[CurrentPageIndex];
public bool CanGoBack => CurrentPageIndex > 0;
public bool IsLastPage => CurrentPageIndex == Pages.Count - 1;
public string NextButtonText => IsLastPage ? "Install" : "Next \u2192";
[ObservableProperty]
private string? _validationError;
public WizardViewModel(PageResolver resolver, InstallContext context)
{
_context = context;
var all = resolver.WizardPages;
Pages = context.Mode == InstallerMode.Update
? all.Where(p => p is WelcomePageViewModel
|| p is InstallPageViewModel).ToList()
: all;
if (Pages.Count > 0)
_ = InitAsync();
}
private async Task InitAsync()
{
try { await Pages[0].LoadAsync(); }
catch { /* first page loads with defaults on error */ }
}
[RelayCommand]
private async Task GoBack()
{
if (!CanGoBack) return;
CurrentPageIndex--;
await CurrentPage.LoadAsync();
ValidationError = null;
}
[RelayCommand]
private async Task GoNext()
{
if (!CurrentPage.Validate())
{
ValidationError = "Please fix the highlighted errors before continuing.";
return;
}
ValidationError = null;
await CurrentPage.ApplyAsync();
if (CurrentPageIndex < Pages.Count - 1)
{
CurrentPageIndex++;
await CurrentPage.LoadAsync();
}
}
[RelayCommand]
private void Close()
{
Application.Current.Shutdown();
}
}

View File

@@ -0,0 +1,95 @@
<Window x:Class="ClaudeDo.Installer.Views.WizardWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Installer"
Width="720" Height="520"
MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen"
d:DataContext="{d:DesignInstance views:WizardViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Step Indicator -->
<Border Grid.Row="0" Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,0,0,1"
Padding="20,14">
<ItemsControl ItemsSource="{Binding Pages}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border x:Name="StepBorder" CornerRadius="4" Padding="10,5" Margin="0,0,6,0"
BorderThickness="1">
<Border.Background>
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="Background">
<Binding/>
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
</MultiBinding>
</Border.Background>
<Border.BorderBrush>
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="BorderBrush">
<Binding/>
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
</MultiBinding>
</Border.BorderBrush>
<TextBlock Text="{Binding Title}" FontSize="12">
<TextBlock.Foreground>
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="Foreground">
<Binding/>
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
</MultiBinding>
</TextBlock.Foreground>
</TextBlock>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- Page Content -->
<Border Grid.Row="1" Padding="24,20">
<ContentControl Content="{Binding CurrentPage.View}"/>
</Border>
<!-- Bottom Bar -->
<Border Grid.Row="2" Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,1,0,0"
Padding="20,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Validation error -->
<TextBlock Grid.Column="0" Text="{Binding ValidationError}"
Foreground="{StaticResource ErrorBrush}"
VerticalAlignment="Center" FontSize="12"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
<Button Grid.Column="1" Content="Back"
Command="{Binding GoBackCommand}"
IsEnabled="{Binding CanGoBack}"
Margin="0,0,8,0" MinWidth="80"/>
<Button Grid.Column="2" Content="{Binding NextButtonText}"
Command="{Binding GoNextCommand}"
Style="{StaticResource AccentButton}"
MinWidth="100"/>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,11 @@
using System.Windows;
namespace ClaudeDo.Installer.Views;
public partial class WizardWindow : Window
{
public WizardWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ClaudeDo.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ClaudeDo.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,106 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class ChecksumVerifierTests : IDisposable
{
private readonly string _tempDir;
public ChecksumVerifierTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoChecksum-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { }
}
[Fact]
public void ComputeSha256_KnownVector_EmptyFile()
{
var path = Path.Combine(_tempDir, "empty.bin");
File.WriteAllBytes(path, Array.Empty<byte>());
var hash = ChecksumVerifier.ComputeSha256(path);
Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
}
[Fact]
public void ComputeSha256_KnownVector_Hello()
{
var path = Path.Combine(_tempDir, "hello.bin");
File.WriteAllText(path, "hello");
var hash = ChecksumVerifier.ComputeSha256(path);
Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash);
}
[Fact]
public void Verify_ReturnsTrue_WhenHashMatches()
{
var path = Path.Combine(_tempDir, "x.bin");
File.WriteAllText(path, "hello");
Assert.True(ChecksumVerifier.Verify(path,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"));
}
[Fact]
public void Verify_IsCaseInsensitive()
{
var path = Path.Combine(_tempDir, "x.bin");
File.WriteAllText(path, "hello");
Assert.True(ChecksumVerifier.Verify(path,
"2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824"));
}
[Fact]
public void Verify_ReturnsFalse_OnMismatch()
{
var path = Path.Combine(_tempDir, "x.bin");
File.WriteAllText(path, "hello");
Assert.False(ChecksumVerifier.Verify(path, new string('0', 64)));
}
[Fact]
public void ParseChecksumsFile_ReadsTwoLines()
{
var content = """
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ClaudeDo-0.2.0-win-x64.zip
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 ClaudeDo.Installer-0.2.0.exe
""";
var map = ChecksumVerifier.ParseChecksumsFile(content);
Assert.Equal(2, map.Count);
Assert.Equal(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
map["ClaudeDo-0.2.0-win-x64.zip"]);
Assert.Equal(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
map["ClaudeDo.Installer-0.2.0.exe"]);
}
[Fact]
public void ParseChecksumsFile_SkipsBlankAndMalformedLines()
{
var content = """
not a line
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file.zip
""";
var map = ChecksumVerifier.ParseChecksumsFile(content);
Assert.Single(map);
Assert.True(map.ContainsKey("file.zip"));
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ClaudeDo.Installer\ClaudeDo.Installer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,148 @@
using System.IO;
using System.IO.Compression;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
namespace ClaudeDo.Installer.Tests;
public sealed class DownloadAndExtractStepTests : IDisposable
{
private readonly string _tempDir;
private readonly string _installDir;
public DownloadAndExtractStepTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDownloadStep-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
_installDir = Path.Combine(_tempDir, "install");
Directory.CreateDirectory(_installDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { }
}
private sealed class FileCopyReleaseClient : IReleaseClient
{
private readonly Dictionary<string, string> _urlToSourceFile;
public GiteaRelease? Release { get; set; }
public FileCopyReleaseClient(Dictionary<string, string> urlToSourceFile)
=> _urlToSourceFile = urlToSourceFile;
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
{
File.Copy(_urlToSourceFile[url], destPath, overwrite: true);
progress.Report(new FileInfo(destPath).Length);
return Task.CompletedTask;
}
}
[Fact]
public async Task Extracts_Zip_Into_InstallDir_App_And_Worker()
{
var zipPath = Path.Combine(_tempDir, "release.zip");
using (var fs = File.Create(zipPath))
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
{
var a = zip.CreateEntry("app/a.txt");
using (var w = new StreamWriter(a.Open())) w.Write("hello-app");
var b = zip.CreateEntry("worker/b.txt");
using (var w = new StreamWriter(b.Open())) w.Write("hello-worker");
}
var zipHash = ChecksumVerifier.ComputeSha256(zipPath);
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
File.WriteAllText(checksumsPath, $"{zipHash} ClaudeDo-0.1.0-win-x64.zip\n");
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
{
new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", new FileInfo(zipPath).Length),
new ReleaseAsset("checksums.txt", "fake://checksums", new FileInfo(checksumsPath).Length),
});
var client = new FileCopyReleaseClient(new()
{
["fake://zip"] = zipPath,
["fake://checksums"] = checksumsPath,
}) { Release = release };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.True(result.Success, result.ErrorMessage);
Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
Assert.Equal("hello-worker", File.ReadAllText(Path.Combine(_installDir, "worker", "b.txt")));
Assert.Equal("0.1.0", ctx.InstalledVersion);
}
[Fact]
public async Task Fails_On_ChecksumMismatch_Without_Overwriting_InstallDir()
{
var zipPath = Path.Combine(_tempDir, "release.zip");
using (var fs = File.Create(zipPath))
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
{
var a = zip.CreateEntry("app/a.txt");
using (var w = new StreamWriter(a.Open())) w.Write("x");
}
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
File.WriteAllText(checksumsPath, $"{new string('0', 64)} ClaudeDo-0.1.0-win-x64.zip\n");
File.WriteAllText(Path.Combine(_installDir, "marker.txt"), "untouched");
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
{
new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", 0),
new ReleaseAsset("checksums.txt", "fake://checksums", 0),
});
var client = new FileCopyReleaseClient(new()
{
["fake://zip"] = zipPath,
["fake://checksums"] = checksumsPath,
}) { Release = release };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("checksum", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
Assert.True(File.Exists(Path.Combine(_installDir, "marker.txt")));
Assert.False(Directory.Exists(Path.Combine(_installDir, "app")));
}
[Fact]
public async Task Fails_When_Release_Has_No_Zip_Asset()
{
var release = new GiteaRelease("v0.1.0", "v0.1.0", Array.Empty<ReleaseAsset>());
var client = new FileCopyReleaseClient(new()) { Release = release };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Fails_When_ReleaseClient_Returns_Null()
{
var client = new FileCopyReleaseClient(new()) { Release = null };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
}
}

View File

@@ -0,0 +1,32 @@
using System.Net;
using System.Net.Http;
namespace ClaudeDo.Installer.Tests;
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
private readonly object _lock = new();
private readonly List<HttpRequestMessage> _requests = new();
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
public FakeHttpMessageHandler(HttpStatusCode status, string body)
: this(_ => new HttpResponseMessage(status) { Content = new StringContent(body) })
{
}
public IReadOnlyList<HttpRequestMessage> Requests
{
get { lock (_lock) return _requests.ToArray(); }
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
lock (_lock) _requests.Add(request);
return Task.FromResult(_handler(request));
}
}

View File

@@ -0,0 +1,76 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class InstallManifestStoreTests : IDisposable
{
private readonly string _tempDir;
public InstallManifestStoreTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoInstallerTests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
}
[Fact]
public void TryRead_ReturnsNull_WhenFileMissing()
{
var result = InstallManifestStore.TryRead(_tempDir);
Assert.Null(result);
}
[Fact]
public void Write_Then_Read_RoundTripsAllFields()
{
var manifest = new InstallManifest(
Version: "0.2.0",
InstallDir: _tempDir,
WorkerDir: Path.Combine(_tempDir, "worker"),
InstalledAt: new DateTimeOffset(2026, 4, 15, 12, 34, 56, TimeSpan.Zero));
InstallManifestStore.Write(_tempDir, manifest);
var round = InstallManifestStore.TryRead(_tempDir);
Assert.NotNull(round);
Assert.Equal("0.2.0", round!.Version);
Assert.Equal(manifest.InstallDir, round.InstallDir);
Assert.Equal(manifest.WorkerDir, round.WorkerDir);
Assert.Equal(manifest.InstalledAt, round.InstalledAt);
}
[Fact]
public void Write_CreatesInstallDir_IfMissing()
{
var nested = Path.Combine(_tempDir, "nested");
Assert.False(Directory.Exists(nested));
InstallManifestStore.Write(nested, new InstallManifest(
"0.0.1", nested, Path.Combine(nested, "worker"), DateTimeOffset.UtcNow));
Assert.True(File.Exists(Path.Combine(nested, "install.json")));
}
[Fact]
public void TryRead_ReturnsNull_WhenJsonMalformed()
{
File.WriteAllText(Path.Combine(_tempDir, "install.json"), "{ not json");
var result = InstallManifestStore.TryRead(_tempDir);
Assert.Null(result);
}
[Fact]
public void TryRead_ReturnsNull_WhenJsonIsValidButShapeIsWrong()
{
// Valid JSON, but installedAt has a wrong type — causes JsonException, swallowed silently.
File.WriteAllText(Path.Combine(_tempDir, "install.json"),
"""{"version":"1.0","installDir":"x","workerDir":"y","installedAt":12345}""");
var result = InstallManifestStore.TryRead(_tempDir);
Assert.Null(result);
}
}

View File

@@ -0,0 +1,124 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class InstallModeDetectorTests : IDisposable
{
private readonly string _tempDir;
public InstallModeDetectorTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDetector-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { }
}
private sealed class FakeReleaseClient : IReleaseClient
{
public GiteaRelease? Release { get; set; }
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
=> throw new NotSupportedException();
}
[Fact]
public async Task Detect_FreshInstall_WhenManifestMissing()
{
var detector = new InstallModeDetector(new FakeReleaseClient());
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.FreshInstall, state.Mode);
Assert.Null(state.Existing);
}
[Fact]
public async Task Detect_Config_WhenManifestPresent_And_Api_Unreachable()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var detector = new InstallModeDetector(new FakeReleaseClient { Release = null });
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
Assert.Equal("0.1.0", state.Existing!.Version);
}
[Fact]
public async Task Detect_Update_WhenLatest_GreaterThan_Installed()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Update, state.Mode);
Assert.Equal("0.1.0", state.Existing!.Version);
Assert.Equal("0.2.0", state.LatestVersion);
}
[Fact]
public async Task Detect_Config_WhenLatest_EqualsOrOlderThan_Installed()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.2.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
}
[Fact]
public async Task Detect_Config_WhenInstalledIs_Newer_Than_Latest()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.3.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
}
[Fact]
public async Task Detect_Config_WhenInstalledVersion_IsUnparseable()
{
// install.json has been tampered with or written by an older installer with a
// version string we can't compare. Must not crash; must land on Config (no update).
InstallManifestStore.Write(_tempDir,
new InstallManifest("garbage", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
}
}

View File

@@ -0,0 +1,109 @@
using System.Net;
using System.Net.Http;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class ReleaseClientTests
{
private const string ApiBase = "https://git.example.test/api/v1/repos/releases/ClaudeDo";
[Fact]
public async Task GetLatestReleaseAsync_ParsesTagAndAssets()
{
const string json = """
{
"tag_name": "v0.2.0",
"name": "v0.2.0",
"assets": [
{
"name": "ClaudeDo-0.2.0-win-x64.zip",
"browser_download_url": "https://git.example.test/dl/zip",
"size": 12345
},
{
"name": "checksums.txt",
"browser_download_url": "https://git.example.test/dl/checksums",
"size": 128
}
]
}
""";
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.NotNull(release);
Assert.Equal("v0.2.0", release!.TagName);
Assert.Equal(2, release.Assets.Count);
Assert.Equal("ClaudeDo-0.2.0-win-x64.zip", release.Assets[0].Name);
Assert.Equal("https://git.example.test/dl/zip", release.Assets[0].BrowserDownloadUrl);
Assert.Equal(12345, release.Assets[0].Size);
}
[Fact]
public async Task GetLatestReleaseAsync_Returns_Null_On404()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound, "");
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.Null(release);
}
[Fact]
public async Task GetLatestReleaseAsync_Returns_Null_OnNetworkError()
{
var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("boom"));
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.Null(release);
}
[Fact]
public async Task GetLatestReleaseAsync_Hits_CorrectUrl()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, "{\"tag_name\":\"v0.1.0\",\"assets\":[]}");
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
_ = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.Single(handler.Requests);
Assert.Equal($"{ApiBase}/releases/latest", handler.Requests[0].RequestUri!.ToString());
}
[Fact]
public async Task DownloadAsync_WritesBytesToDisk()
{
var payload = new byte[] { 1, 2, 3, 4, 5 };
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload)
});
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var tempPath = Path.Combine(Path.GetTempPath(), "ClaudeDoDlTest-" + Guid.NewGuid().ToString("N"));
try
{
await client.DownloadAsync("https://example/foo", tempPath,
new Progress<long>(_ => { }), CancellationToken.None);
Assert.True(File.Exists(tempPath));
Assert.Equal(payload, File.ReadAllBytes(tempPath));
}
finally
{
if (File.Exists(tempPath)) File.Delete(tempPath);
}
}
}

View File

@@ -0,0 +1,51 @@
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
namespace ClaudeDo.Installer.Tests;
public sealed class WriteInstallManifestStepTests : IDisposable
{
private readonly string _installDir;
public WriteInstallManifestStepTests()
{
_installDir = Path.Combine(Path.GetTempPath(), "ClaudeDoWriteManifest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_installDir);
}
public void Dispose()
{
try { Directory.Delete(_installDir, recursive: true); } catch { }
}
[Fact]
public async Task Writes_Manifest_WithAllFields()
{
var ctx = new InstallContext
{
InstallDirectory = _installDir,
InstalledVersion = "0.2.0",
};
var step = new WriteInstallManifestStep();
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.True(result.Success);
var manifest = InstallManifestStore.TryRead(_installDir);
Assert.NotNull(manifest);
Assert.Equal("0.2.0", manifest!.Version);
Assert.Equal(_installDir, manifest.InstallDir);
Assert.Equal(Path.Combine(_installDir, "worker"), manifest.WorkerDir);
}
[Fact]
public async Task Fails_When_InstalledVersion_Missing()
{
var ctx = new InstallContext { InstallDirectory = _installDir }; // no version set
var step = new WriteInstallManifestStep();
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
}
}