Compare commits
31 Commits
78831b2263
...
feat/insta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a8d78d4a | ||
|
|
b5455a1965 | ||
|
|
5d42438a72 | ||
|
|
2898bec314 | ||
|
|
ac38ea8c34 | ||
|
|
8d2f7e9907 | ||
|
|
da1fe2109a | ||
|
|
5e432a4a27 | ||
|
|
01c29bb6f6 | ||
|
|
12e532718c | ||
|
|
fe913ae5ef | ||
|
|
4fab0481c4 | ||
|
|
0989176127 | ||
|
|
548251841f | ||
|
|
ea32a74baa | ||
|
|
c1e330164e | ||
|
|
5b4af29420 | ||
|
|
d87de152e0 | ||
|
|
b4dc9509cb | ||
|
|
97fb215ce6 | ||
|
|
83d7058b32 | ||
|
|
5603fd458d | ||
|
|
d0c0e2ce1f | ||
|
|
2fc6924dcb | ||
|
|
921e626208 | ||
|
|
c23ed94817 | ||
|
|
2d34afb2e5 | ||
|
|
c0bd46542a | ||
|
|
0498fbae47 | ||
|
|
43a10cff95 | ||
|
|
bd7d5940a2 |
@@ -9,5 +9,6 @@
|
||||
<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>
|
||||
|
||||
2382
docs/superpowers/plans/2026-04-15-installer-download-mode.md
Normal file
2382
docs/superpowers/plans/2026-04-15-installer-download-mode.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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`.
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Pages.InstallPage;
|
||||
@@ -15,24 +17,53 @@ public partial class App : Application
|
||||
{
|
||||
private ServiceProvider? _services;
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
protected override async void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
var mode = ModeDetector.Detect();
|
||||
_services = BuildServices();
|
||||
|
||||
Window mainWindow = mode switch
|
||||
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
|
||||
{
|
||||
InstallerMode.Wizard => new WizardWindow
|
||||
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.Settings => new SettingsWindow
|
||||
InstallerMode.Config => new SettingsWindow
|
||||
{
|
||||
DataContext = _services.GetRequiredService<SettingsViewModel>()
|
||||
},
|
||||
_ => throw new InvalidOperationException($"Unknown installer mode: {mode}")
|
||||
_ => throw new InvalidOperationException($"Unknown installer mode: {state.Mode}")
|
||||
};
|
||||
|
||||
DarkTitleBar.Apply(mainWindow);
|
||||
@@ -45,6 +76,13 @@ public partial class App : Application
|
||||
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();
|
||||
@@ -52,7 +90,10 @@ public partial class App : Application
|
||||
// Core
|
||||
sc.AddSingleton<InstallContext>();
|
||||
sc.AddSingleton<PageResolver>();
|
||||
sc.AddSingleton<InstallerService>();
|
||||
// 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>();
|
||||
@@ -61,14 +102,25 @@ public partial class App : Application
|
||||
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
|
||||
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
|
||||
|
||||
// Steps (registration order = execution order)
|
||||
sc.AddSingleton<IInstallStep, PublishAppStep>();
|
||||
sc.AddSingleton<IInstallStep, PublishWorkerStep>();
|
||||
sc.AddSingleton<IInstallStep, DeployBinariesStep>();
|
||||
// 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>();
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
<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" />
|
||||
|
||||
37
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
Normal file
37
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
15
src/ClaudeDo.Installer/Core/IReleaseClient.cs
Normal file
15
src/ClaudeDo.Installer/Core/IReleaseClient.cs
Normal 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);
|
||||
}
|
||||
@@ -2,10 +2,15 @@ namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public sealed class InstallContext
|
||||
{
|
||||
// WelcomePage
|
||||
public string SourceDirectory { get; set; } = "";
|
||||
// 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";
|
||||
|
||||
48
src/ClaudeDo.Installer/Core/InstallManifest.cs
Normal file
48
src/ClaudeDo.Installer/Core/InstallManifest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
48
src/ClaudeDo.Installer/Core/InstallModeDetector.cs
Normal file
48
src/ClaudeDo.Installer/Core/InstallModeDetector.cs
Normal 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 > 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;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Data;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public enum InstallerMode { Wizard, Settings }
|
||||
|
||||
public static class ModeDetector
|
||||
public enum InstallerMode
|
||||
{
|
||||
public static InstallerMode Detect()
|
||||
{
|
||||
var root = Paths.AppDataRoot();
|
||||
var workerConfig = Path.Combine(root, "worker.config.json");
|
||||
var uiConfig = Path.Combine(root, "ui.config.json");
|
||||
return File.Exists(workerConfig) && File.Exists(uiConfig)
|
||||
? InstallerMode.Settings
|
||||
: InstallerMode.Wizard;
|
||||
}
|
||||
FreshInstall, // No install.json present -> run full wizard
|
||||
Update, // install.json present, newer release available
|
||||
Config, // install.json present, no update (or API unreachable)
|
||||
}
|
||||
|
||||
85
src/ClaudeDo.Installer/Core/ReleaseClient.cs
Normal file
85
src/ClaudeDo.Installer/Core/ReleaseClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
127
src/ClaudeDo.Installer/Core/UninstallRunner.cs
Normal file
127
src/ClaudeDo.Installer/Core/UninstallRunner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,17 @@ 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 InstallerService _installerService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private InstallPageView? _view;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
@@ -29,22 +31,31 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
[ObservableProperty] private bool _hasErrors;
|
||||
[ObservableProperty] private double _overallProgress;
|
||||
|
||||
public InstallPageViewModel(InstallContext context, InstallerService installerService)
|
||||
public InstallPageViewModel(InstallContext context, IServiceProvider serviceProvider)
|
||||
{
|
||||
_context = context;
|
||||
_installerService = installerService;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public Task LoadAsync()
|
||||
{
|
||||
Steps.Clear();
|
||||
Steps.Add(new StepViewModel("Publish ClaudeDo.App"));
|
||||
Steps.Add(new StepViewModel("Publish ClaudeDo.Worker"));
|
||||
Steps.Add(new StepViewModel("Deploy Binaries"));
|
||||
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"));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -85,7 +96,24 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
|
||||
try
|
||||
{
|
||||
var results = await _installerService.ExecuteAsync(_context, progress, _cts.Token);
|
||||
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)
|
||||
|
||||
@@ -9,40 +9,29 @@
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel MaxWidth="520">
|
||||
<TextBlock Text="Welcome to ClaudeDo Setup" FontSize="20" FontWeight="SemiBold"
|
||||
Margin="0,0,0,6"/>
|
||||
<TextBlock Text="This wizard will build, configure, and install ClaudeDo on your machine."
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,24"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<!-- Source Directory -->
|
||||
<Label Content="Source Directory"/>
|
||||
<Grid Margin="0,0,0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0" Text="{Binding SourceDirectory, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<Button Grid.Column="1" Content="Browse..." Command="{Binding BrowseSourceCommand}"
|
||||
Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding SourceError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
|
||||
Visibility="{Binding SourceError, Converter={StaticResource NullToCollapsedConverter}}"
|
||||
Margin="0,0,0,16"/>
|
||||
<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"/>
|
||||
|
||||
<!-- Install Directory -->
|
||||
<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}"/>
|
||||
<Button Grid.Column="1" Content="Browse..." Command="{Binding BrowseInstallCommand}"
|
||||
Margin="8,0,0,0"/>
|
||||
<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>
|
||||
|
||||
@@ -19,88 +19,69 @@ public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
|
||||
public bool ShowInSettings => false;
|
||||
public UserControl View => _view ??= new WelcomePageView { DataContext = this };
|
||||
|
||||
[ObservableProperty] private string _sourceDirectory = "";
|
||||
[ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
|
||||
[ObservableProperty] private string? _sourceError;
|
||||
[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;
|
||||
_sourceDirectory = DetectSourceDirectory();
|
||||
}
|
||||
|
||||
public Task LoadAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_context.SourceDirectory))
|
||||
SourceDirectory = _context.SourceDirectory;
|
||||
if (!string.IsNullOrEmpty(_context.InstallDirectory))
|
||||
InstallDirectory = _context.InstallDirectory;
|
||||
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.SourceDirectory = SourceDirectory;
|
||||
_context.InstallDirectory = InstallDirectory;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool Validate()
|
||||
{
|
||||
var valid = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SourceDirectory) ||
|
||||
!File.Exists(Path.Combine(SourceDirectory, "ClaudeDo.slnx")))
|
||||
{
|
||||
SourceError = "Source directory must contain ClaudeDo.slnx";
|
||||
valid = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
SourceError = null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(InstallDirectory))
|
||||
{
|
||||
InstallError = "Install directory is required";
|
||||
valid = false;
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
InstallError = null;
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void BrowseSource()
|
||||
{
|
||||
var dialog = new OpenFolderDialog { Title = "Select ClaudeDo source directory" };
|
||||
if (dialog.ShowDialog() == true)
|
||||
SourceDirectory = dialog.FolderName;
|
||||
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;
|
||||
}
|
||||
|
||||
private static string DetectSourceDirectory()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "ClaudeDo.slnx")))
|
||||
return dir;
|
||||
var parent = Directory.GetParent(dir)?.FullName;
|
||||
if (parent is null) break;
|
||||
dir = parent;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class DeployBinariesStep : IInstallStep
|
||||
{
|
||||
public string Name => "Deploy Binaries";
|
||||
|
||||
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appPublish = Path.Combine(ctx.SourceDirectory, "src", "ClaudeDo.App", "bin", "Release", "net8.0", "win-x64", "publish");
|
||||
var workerPublish = Path.Combine(ctx.SourceDirectory, "src", "ClaudeDo.Worker", "bin", "Release", "net8.0", "win-x64", "publish");
|
||||
|
||||
var appDest = Path.Combine(ctx.InstallDirectory, "app");
|
||||
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
|
||||
|
||||
if (!Directory.Exists(appPublish))
|
||||
return Task.FromResult(StepResult.Fail($"App publish directory not found: {appPublish}"));
|
||||
if (!Directory.Exists(workerPublish))
|
||||
return Task.FromResult(StepResult.Fail($"Worker publish directory not found: {workerPublish}"));
|
||||
|
||||
var appCount = CopyDirectory(appPublish, appDest, progress, ct);
|
||||
progress.Report($"Copied {appCount} files to {appDest}");
|
||||
|
||||
var workerCount = CopyDirectory(workerPublish, workerDest, progress, ct);
|
||||
progress.Report($"Copied {workerCount} files to {workerDest}");
|
||||
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(StepResult.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static int CopyDirectory(string source, string dest, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(dest);
|
||||
var count = 0;
|
||||
|
||||
foreach (var dir in Directory.GetDirectories(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var relative = Path.GetRelativePath(source, dir);
|
||||
Directory.CreateDirectory(Path.Combine(dest, relative));
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var relative = Path.GetRelativePath(source, file);
|
||||
var destFile = Path.Combine(dest, relative);
|
||||
File.Copy(file, destFile, overwrite: true);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
91
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
Normal file
91
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class PublishAppStep : IInstallStep
|
||||
{
|
||||
public string Name => "Publish ClaudeDo.App";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Publishing ClaudeDo.App...");
|
||||
|
||||
var args = "publish src/ClaudeDo.App/ClaudeDo.App.csproj -c Release -r win-x64 --self-contained false";
|
||||
var (exitCode, output) = await ProcessRunner.RunAsync("dotnet", args, ctx.SourceDirectory, progress, ct);
|
||||
|
||||
return exitCode == 0
|
||||
? StepResult.Ok()
|
||||
: StepResult.Fail($"dotnet publish failed with exit code {exitCode}");
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class PublishWorkerStep : IInstallStep
|
||||
{
|
||||
public string Name => "Publish ClaudeDo.Worker";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Publishing ClaudeDo.Worker...");
|
||||
|
||||
var args = "publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release -r win-x64 --self-contained false";
|
||||
var (exitCode, output) = await ProcessRunner.RunAsync("dotnet", args, ctx.SourceDirectory, progress, ct);
|
||||
|
||||
return exitCode == 0
|
||||
? StepResult.Ok()
|
||||
: StepResult.Fail($"dotnet publish failed with exit code {exitCode}");
|
||||
}
|
||||
}
|
||||
27
src/ClaudeDo.Installer/Steps/StartServiceStep.cs
Normal file
27
src/ClaudeDo.Installer/Steps/StartServiceStep.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
48
src/ClaudeDo.Installer/Steps/StopServiceStep.cs
Normal file
48
src/ClaudeDo.Installer/Steps/StopServiceStep.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
32
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
Normal file
32
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Windows;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
@@ -8,6 +9,11 @@ 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; }
|
||||
|
||||
@@ -20,12 +26,29 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private bool _isStatusError;
|
||||
|
||||
public SettingsViewModel(PageResolver resolver, InstallContext context)
|
||||
[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();
|
||||
}
|
||||
|
||||
@@ -36,9 +59,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Apply()
|
||||
private async Task Save()
|
||||
{
|
||||
// Validate all pages
|
||||
foreach (var page in Pages)
|
||||
{
|
||||
if (!page.Validate())
|
||||
@@ -50,11 +72,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all pages (writes to InstallContext)
|
||||
foreach (var page in Pages)
|
||||
await page.ApplyAsync();
|
||||
|
||||
// Write config files directly
|
||||
var workerCfg = new InstallerWorkerConfig
|
||||
{
|
||||
DbPath = _context.DbPath,
|
||||
@@ -75,13 +95,67 @@ public partial class SettingsViewModel : ObservableObject
|
||||
};
|
||||
uiCfg.Save();
|
||||
|
||||
StatusMessage = "Settings saved successfully.";
|
||||
StatusMessage = "Settings saved.";
|
||||
IsStatusError = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close()
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -61,31 +61,37 @@
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Status message -->
|
||||
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
|
||||
VerticalAlignment="Center" 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>
|
||||
<!-- 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="Close"
|
||||
Command="{Binding CloseCommand}"
|
||||
Margin="0,0,8,0" MinWidth="80"/>
|
||||
|
||||
<Button Grid.Column="2" Content="Save & Apply"
|
||||
Command="{Binding ApplyCommand}"
|
||||
Style="{StaticResource AccentButton}"
|
||||
MinWidth="100"/>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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;
|
||||
|
||||
@@ -28,9 +31,14 @@ public partial class WizardViewModel : ObservableObject
|
||||
|
||||
public WizardViewModel(PageResolver resolver, InstallContext context)
|
||||
{
|
||||
Pages = resolver.WizardPages;
|
||||
_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();
|
||||
}
|
||||
|
||||
106
tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
Normal file
106
tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
148
tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
Normal file
148
tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
32
tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs
Normal file
32
tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
76
tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs
Normal file
76
tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
124
tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
Normal file
124
tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
109
tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
Normal file
109
tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user