Compare commits
31 Commits
78831b2263
...
b7a8d78d4a
| 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/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
||||||
|
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</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 System.Windows;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
using ClaudeDo.Installer.Pages.InstallPage;
|
using ClaudeDo.Installer.Pages.InstallPage;
|
||||||
@@ -15,24 +17,53 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
private ServiceProvider? _services;
|
private ServiceProvider? _services;
|
||||||
|
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
protected override async void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
|
||||||
var mode = ModeDetector.Detect();
|
|
||||||
_services = BuildServices();
|
_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>()
|
DataContext = _services.GetRequiredService<WizardViewModel>()
|
||||||
},
|
},
|
||||||
InstallerMode.Settings => new SettingsWindow
|
InstallerMode.Config => new SettingsWindow
|
||||||
{
|
{
|
||||||
DataContext = _services.GetRequiredService<SettingsViewModel>()
|
DataContext = _services.GetRequiredService<SettingsViewModel>()
|
||||||
},
|
},
|
||||||
_ => throw new InvalidOperationException($"Unknown installer mode: {mode}")
|
_ => throw new InvalidOperationException($"Unknown installer mode: {state.Mode}")
|
||||||
};
|
};
|
||||||
|
|
||||||
DarkTitleBar.Apply(mainWindow);
|
DarkTitleBar.Apply(mainWindow);
|
||||||
@@ -45,6 +76,13 @@ public partial class App : Application
|
|||||||
base.OnExit(e);
|
base.OnExit(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetInstallerVersion()
|
||||||
|
{
|
||||||
|
var infoAttr = Assembly.GetExecutingAssembly()
|
||||||
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||||
|
return infoAttr?.InformationalVersion ?? "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
private static ServiceProvider BuildServices()
|
private static ServiceProvider BuildServices()
|
||||||
{
|
{
|
||||||
var sc = new ServiceCollection();
|
var sc = new ServiceCollection();
|
||||||
@@ -52,7 +90,10 @@ public partial class App : Application
|
|||||||
// Core
|
// Core
|
||||||
sc.AddSingleton<InstallContext>();
|
sc.AddSingleton<InstallContext>();
|
||||||
sc.AddSingleton<PageResolver>();
|
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
|
// Pages
|
||||||
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
|
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
|
||||||
@@ -61,14 +102,25 @@ public partial class App : Application
|
|||||||
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
|
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
|
||||||
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
|
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
|
||||||
|
|
||||||
// Steps (registration order = execution order)
|
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
|
||||||
sc.AddSingleton<IInstallStep, PublishAppStep>();
|
// Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline
|
||||||
sc.AddSingleton<IInstallStep, PublishWorkerStep>();
|
// can pull them out individually via GetRequiredService<T>().
|
||||||
sc.AddSingleton<IInstallStep, DeployBinariesStep>();
|
sc.AddSingleton<DownloadAndExtractStep>();
|
||||||
|
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
|
||||||
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
||||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||||
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
||||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
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
|
// ViewModels
|
||||||
sc.AddSingleton<WizardViewModel>();
|
sc.AddSingleton<WizardViewModel>();
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.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
|
public sealed class InstallContext
|
||||||
{
|
{
|
||||||
// WelcomePage
|
// WelcomePage / install destination
|
||||||
public string SourceDirectory { get; set; } = "";
|
|
||||||
public string InstallDirectory { get; set; } = @"C:\Program Files\ClaudeDo";
|
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
|
// PathsPage
|
||||||
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
||||||
public string LogRoot { get; set; } = "~/.todo-app/logs";
|
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;
|
namespace ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
public enum InstallerMode { Wizard, Settings }
|
public enum InstallerMode
|
||||||
|
|
||||||
public static class ModeDetector
|
|
||||||
{
|
{
|
||||||
public static InstallerMode Detect()
|
FreshInstall, // No install.json present -> run full wizard
|
||||||
{
|
Update, // install.json present, newer release available
|
||||||
var root = Paths.AppDataRoot();
|
Config, // install.json present, no update (or API unreachable)
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Installer.Steps;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Pages.InstallPage;
|
namespace ClaudeDo.Installer.Pages.InstallPage;
|
||||||
|
|
||||||
public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||||
{
|
{
|
||||||
private readonly InstallContext _context;
|
private readonly InstallContext _context;
|
||||||
private readonly InstallerService _installerService;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private InstallPageView? _view;
|
private InstallPageView? _view;
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
@@ -29,22 +31,31 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
|||||||
[ObservableProperty] private bool _hasErrors;
|
[ObservableProperty] private bool _hasErrors;
|
||||||
[ObservableProperty] private double _overallProgress;
|
[ObservableProperty] private double _overallProgress;
|
||||||
|
|
||||||
public InstallPageViewModel(InstallContext context, InstallerService installerService)
|
public InstallPageViewModel(InstallContext context, IServiceProvider serviceProvider)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_installerService = installerService;
|
_serviceProvider = serviceProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task LoadAsync()
|
public Task LoadAsync()
|
||||||
{
|
{
|
||||||
Steps.Clear();
|
Steps.Clear();
|
||||||
Steps.Add(new StepViewModel("Publish ClaudeDo.App"));
|
if (_context.Mode == InstallerMode.Update)
|
||||||
Steps.Add(new StepViewModel("Publish ClaudeDo.Worker"));
|
{
|
||||||
Steps.Add(new StepViewModel("Deploy Binaries"));
|
Steps.Add(new StepViewModel("Stop Worker Service"));
|
||||||
Steps.Add(new StepViewModel("Write Configuration"));
|
Steps.Add(new StepViewModel("Download and Extract"));
|
||||||
Steps.Add(new StepViewModel("Initialize Database"));
|
Steps.Add(new StepViewModel("Start Worker Service"));
|
||||||
Steps.Add(new StepViewModel("Register Windows Service"));
|
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||||
Steps.Add(new StepViewModel("Create Shortcuts"));
|
}
|
||||||
|
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;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +96,24 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
|||||||
|
|
||||||
try
|
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);
|
HasErrors = results.Any(r => !r.Result.Success);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
|
|||||||
@@ -9,40 +9,29 @@
|
|||||||
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel MaxWidth="520">
|
<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 -->
|
<TextBlock Text="{Binding Heading}" FontSize="20" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||||
<Label Content="Source Directory"/>
|
<TextBlock Text="{Binding Subheading}" TextWrapping="Wrap"
|
||||||
<Grid Margin="0,0,0,4">
|
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,24"/>
|
||||||
<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"/>
|
|
||||||
|
|
||||||
<!-- Install Directory -->
|
|
||||||
<Label Content="Install Directory"/>
|
<Label Content="Install Directory"/>
|
||||||
<Grid Margin="0,0,0,4">
|
<Grid Margin="0,0,0,4">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBox Grid.Column="0" Text="{Binding InstallDirectory, UpdateSourceTrigger=PropertyChanged}"/>
|
<TextBox Grid.Column="0"
|
||||||
<Button Grid.Column="1" Content="Browse..." Command="{Binding BrowseInstallCommand}"
|
Text="{Binding InstallDirectory, UpdateSourceTrigger=PropertyChanged}"
|
||||||
Margin="8,0,0,0"/>
|
IsEnabled="{Binding InstallDirEditable}"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Browse..."
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
Command="{Binding BrowseInstallCommand}"
|
||||||
|
IsEnabled="{Binding InstallDirEditable}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
|
<TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
|
||||||
Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/>
|
Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -19,88 +19,69 @@ public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
|
|||||||
public bool ShowInSettings => false;
|
public bool ShowInSettings => false;
|
||||||
public UserControl View => _view ??= new WelcomePageView { DataContext = this };
|
public UserControl View => _view ??= new WelcomePageView { DataContext = this };
|
||||||
|
|
||||||
[ObservableProperty] private string _sourceDirectory = "";
|
|
||||||
[ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
|
[ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
|
||||||
[ObservableProperty] private string? _sourceError;
|
|
||||||
[ObservableProperty] private string? _installError;
|
[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)
|
public WelcomePageViewModel(InstallContext context)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_sourceDirectory = DetectSourceDirectory();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task LoadAsync()
|
public Task LoadAsync()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(_context.SourceDirectory))
|
InstallDirectory = string.IsNullOrEmpty(_context.InstallDirectory)
|
||||||
SourceDirectory = _context.SourceDirectory;
|
? @"C:\Program Files\ClaudeDo"
|
||||||
if (!string.IsNullOrEmpty(_context.InstallDirectory))
|
: _context.InstallDirectory;
|
||||||
InstallDirectory = _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;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ApplyAsync()
|
public Task ApplyAsync()
|
||||||
{
|
{
|
||||||
_context.SourceDirectory = SourceDirectory;
|
|
||||||
_context.InstallDirectory = InstallDirectory;
|
_context.InstallDirectory = InstallDirectory;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Validate()
|
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))
|
if (string.IsNullOrWhiteSpace(InstallDirectory))
|
||||||
{
|
{
|
||||||
InstallError = "Install directory is required";
|
InstallError = "Install directory is required";
|
||||||
valid = false;
|
return false;
|
||||||
}
|
}
|
||||||
else
|
InstallError = null;
|
||||||
{
|
return true;
|
||||||
InstallError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void BrowseSource()
|
|
||||||
{
|
|
||||||
var dialog = new OpenFolderDialog { Title = "Select ClaudeDo source directory" };
|
|
||||||
if (dialog.ShowDialog() == true)
|
|
||||||
SourceDirectory = dialog.FolderName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void BrowseInstall()
|
private void BrowseInstall()
|
||||||
{
|
{
|
||||||
|
if (!InstallDirEditable) return;
|
||||||
var dialog = new OpenFolderDialog { Title = "Select installation directory" };
|
var dialog = new OpenFolderDialog { Title = "Select installation directory" };
|
||||||
if (dialog.ShowDialog() == true)
|
if (dialog.ShowDialog() == true)
|
||||||
InstallDirectory = dialog.FolderName;
|
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 System.Windows;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Installer.Steps;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
@@ -8,6 +9,11 @@ namespace ClaudeDo.Installer.Views;
|
|||||||
public partial class SettingsViewModel : ObservableObject
|
public partial class SettingsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly InstallContext _context;
|
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; }
|
public IReadOnlyList<IInstallerPage> Pages { get; }
|
||||||
|
|
||||||
@@ -20,12 +26,29 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isStatusError;
|
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;
|
Pages = resolver.SettingsPages;
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_releases = releases;
|
||||||
|
_stopService = stopService;
|
||||||
|
_startService = startService;
|
||||||
|
_downloadStep = downloadStep;
|
||||||
|
_uninstallRunner = uninstallRunner;
|
||||||
_selectedPage = Pages.FirstOrDefault();
|
_selectedPage = Pages.FirstOrDefault();
|
||||||
|
|
||||||
|
VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
|
||||||
|
|
||||||
_ = LoadAllAsync();
|
_ = LoadAllAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +59,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task Apply()
|
private async Task Save()
|
||||||
{
|
{
|
||||||
// Validate all pages
|
|
||||||
foreach (var page in Pages)
|
foreach (var page in Pages)
|
||||||
{
|
{
|
||||||
if (!page.Validate())
|
if (!page.Validate())
|
||||||
@@ -50,11 +72,9 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply all pages (writes to InstallContext)
|
|
||||||
foreach (var page in Pages)
|
foreach (var page in Pages)
|
||||||
await page.ApplyAsync();
|
await page.ApplyAsync();
|
||||||
|
|
||||||
// Write config files directly
|
|
||||||
var workerCfg = new InstallerWorkerConfig
|
var workerCfg = new InstallerWorkerConfig
|
||||||
{
|
{
|
||||||
DbPath = _context.DbPath,
|
DbPath = _context.DbPath,
|
||||||
@@ -75,13 +95,67 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
};
|
};
|
||||||
uiCfg.Save();
|
uiCfg.Save();
|
||||||
|
|
||||||
StatusMessage = "Settings saved successfully.";
|
StatusMessage = "Settings saved.";
|
||||||
IsStatusError = false;
|
IsStatusError = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[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();
|
Application.Current.Shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Close() => Application.Current.Shutdown();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,31 +61,37 @@
|
|||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- Status message -->
|
<!-- Status message / version label -->
|
||||||
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
|
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||||
VerticalAlignment="Center" FontSize="12">
|
<TextBlock Text="{Binding VersionLabel}" FontSize="11" Opacity="0.7"/>
|
||||||
<TextBlock.Style>
|
<TextBlock Text="{Binding StatusMessage}" FontSize="12">
|
||||||
<Style TargetType="TextBlock">
|
<TextBlock.Style>
|
||||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
|
<Style TargetType="TextBlock">
|
||||||
<Style.Triggers>
|
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
|
||||||
<DataTrigger Binding="{Binding IsStatusError}" Value="True">
|
<Style.Triggers>
|
||||||
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
|
<DataTrigger Binding="{Binding IsStatusError}" Value="True">
|
||||||
</DataTrigger>
|
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
|
||||||
</Style.Triggers>
|
</DataTrigger>
|
||||||
</Style>
|
</Style.Triggers>
|
||||||
</TextBlock.Style>
|
</Style>
|
||||||
</TextBlock>
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<Button Grid.Column="1" Content="Close"
|
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
|
||||||
Command="{Binding CloseCommand}"
|
Command="{Binding UninstallCommand}"/>
|
||||||
Margin="0,0,8,0" MinWidth="80"/>
|
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
|
||||||
|
Command="{Binding RepairCommand}"/>
|
||||||
<Button Grid.Column="2" Content="Save & Apply"
|
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
|
||||||
Command="{Binding ApplyCommand}"
|
Command="{Binding SaveCommand}"
|
||||||
Style="{StaticResource AccentButton}"
|
Style="{StaticResource AccentButton}"/>
|
||||||
MinWidth="100"/>
|
<Button Grid.Column="4" Content="Close"
|
||||||
|
Command="{Binding CloseCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using System.Linq;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Installer.Pages.InstallPage;
|
||||||
|
using ClaudeDo.Installer.Pages.WelcomePage;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
@@ -28,9 +31,14 @@ public partial class WizardViewModel : ObservableObject
|
|||||||
|
|
||||||
public WizardViewModel(PageResolver resolver, InstallContext context)
|
public WizardViewModel(PageResolver resolver, InstallContext context)
|
||||||
{
|
{
|
||||||
Pages = resolver.WizardPages;
|
|
||||||
_context = context;
|
_context = context;
|
||||||
|
|
||||||
|
var all = resolver.WizardPages;
|
||||||
|
Pages = context.Mode == InstallerMode.Update
|
||||||
|
? all.Where(p => p is WelcomePageViewModel
|
||||||
|
|| p is InstallPageViewModel).ToList()
|
||||||
|
: all;
|
||||||
|
|
||||||
if (Pages.Count > 0)
|
if (Pages.Count > 0)
|
||||||
_ = InitAsync();
|
_ = 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