From bca8ca520c8c5435fbcbe3b01aff86cdc2406bb9 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:32:08 +0200 Subject: [PATCH 1/4] xpp-lens 1.3.0-beta.1 (test release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Added - Test releases: a version can first be published as `X.Y.Z-beta.N`, marked as a pre-release on GitHub. `xpplens update` never offers it; `xpplens update --install --pre` installs the newest release including test releases, so a new version can be tried on one machine before everyone gets it. The regular `X.Y.Z` later replaces a test release through the usual `xpplens update --install`. - `xpplens version`, `status` and the MCP server report the full version of a test release (`1.3.0-beta.1`); versions compare with test releases before their final version (`1.3.0-beta.2` < `1.3.0`). ### Development - Work happens on the branch `dev`; `main` always holds the last regular release. `release.ps1 -Version X.Y.Z -Pre` publishes a test release from `dev`; `release.ps1 -Version X.Y.Z` opens the pull request `dev` → `main`, waits for its CI, merges it (merge commit), tags the merge and publishes the release, then moves `dev` to `main`. CI also runs for pushes to `dev`. The release notes of a test release come from the section of its final version. --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 6 +- CHANGELOG.md | 16 +++ README.md | 15 +- README.pl.md | 15 +- release-notes.ps1 | 23 ++- release.ps1 | 193 +++++++++++++++++--------- src/XppLens/CommandLine.cs | 2 +- src/XppLens/Program.cs | 8 +- src/XppLens/Updater.cs | 97 ++++++++++--- src/XppLens/XppLens.csproj | 2 +- tests/XppLens.Tests/MigrationTests.cs | 46 +++++- 12 files changed, 316 insertions(+), 109 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18c690d..f3f926e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: branches: [main] workflow_dispatch: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b36a47..f4c02ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,8 +45,12 @@ jobs: shell: powershell run: .\pack.ps1 -OutDir dist + # vX.Y.Z-beta.N is a test release: marked as pre-release, never "Latest", so 'xpplens update' does not offer it + # ('xpplens update --pre' does). - name: Publish the release shell: pwsh env: GH_TOKEN: ${{ github.token }} - run: gh release create $env:TAG "dist/xpp-lens-$env:VERSION.zip" --title "xpp-lens $env:VERSION" --notes-file dist/notes.md --verify-tag --latest + run: | + $kind = if ($env:TAG -match '-') { '--prerelease' } else { '--latest' } + gh release create $env:TAG "dist/xpp-lens-$env:VERSION.zip" --title "xpp-lens $env:VERSION" --notes-file dist/notes.md --verify-tag $kind diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cdcee..4cb298a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to xpp-lens (called xpp-graft up to 1.2.0). Versions follow `` in `src/XppLens/XppLens.csproj`; release tags are `vX.Y.Z`. +## 1.3.0 — unreleased + +### Added +- Test releases: a version can first be published as `X.Y.Z-beta.N`, marked as a pre-release on GitHub. + `xpplens update` never offers it; `xpplens update --install --pre` installs the newest release including test + releases, so a new version can be tried on one machine before everyone gets it. The regular `X.Y.Z` later + replaces a test release through the usual `xpplens update --install`. +- `xpplens version`, `status` and the MCP server report the full version of a test release (`1.3.0-beta.1`); versions + compare with test releases before their final version (`1.3.0-beta.2` < `1.3.0`). + +### Development +- Work happens on the branch `dev`; `main` always holds the last regular release. `release.ps1 -Version X.Y.Z -Pre` + publishes a test release from `dev`; `release.ps1 -Version X.Y.Z` opens the pull request `dev` → `main`, waits + for its CI, merges it (merge commit), tags the merge and publishes the release, then moves `dev` to `main`. + CI also runs for pushes to `dev`. The release notes of a test release come from the section of its final version. + ## 1.2.9 — 2026-10-14 ### Fixed diff --git a/README.md b/README.md index b1900d2..e6851c9 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ it (SHA-256 checked) and runs its installer — settings and index are kept. Run does the same. Claude does not have to be closed: an `xpplens.exe` used by an open session is renamed to `*.old` (deleted later), so that session keeps the old version and new sessions start the new one. At the end the installer offers to restart Claude Desktop (`xpplens update --install --restart-claude` or `install.ps1 -RestartClaude`: without -asking); in Claude Code `/mcp` → reconnect xpp-lens does the same for one session. +asking); in Claude Code `/mcp` → reconnect xpp-lens does the same for one session. Test releases (`X.Y.Z-beta.N`) +are offered only with `--pre`: `xpplens update --install --pre`. Useful parameters: @@ -255,15 +256,19 @@ Adding a test: put the XML the case needs into `Fixtures` (keep names neutral ### Releasing a new version -Add the section `## X.Y.Z — unreleased` to `CHANGELOG.md`, then: +Work happens on the branch `dev`; `main` always holds the last regular release. Add the section +`## X.Y.Z — unreleased` to `CHANGELOG.md`, then on `dev`: ```powershell -.\release.ps1 -Version X.Y.Z # everything below, stops at the first failure +.\release.ps1 -Version X.Y.Z -Pre # test release X.Y.Z-beta.N (pre-release; xpplens update --install --pre) +.\release.ps1 -Version X.Y.Z # regular release: pull request dev -> main, merge, tag, publish .\release.ps1 -Version X.Y.Z -DryRun # checks, scan, build and tests only ``` -It dates the section, sets ``, scans every file for private names, builds and tests, commits and pushes -`main`, waits for CI, tags, waits for the Release workflow and checks the published package (SHA-256, layout). +Both stop at the first failure. They set ``, scan every file for private names, build and test, commit +and push `dev` and wait for CI. A test release is then tagged on `dev`; a regular release dates the CHANGELOG +section, merges `dev` into `main` through a pull request (merge commit) and is tagged on `main`, after which `dev` is +moved to `main`. Both wait for the Release workflow and check the published package (SHA-256, layout, kind). The private names (customers, partners, people) come from a local list that is never part of the repository: `-ScanList `, the `XPPLENS_SCAN_LIST` variable, or `%USERPROFILE%\.claude\skills\xpp-lens-release\scan-names.txt` — one regular expression per line, `!word` for allowed words. Without the list the release stops. diff --git a/README.pl.md b/README.pl.md index 5ded82e..c446ac6 100644 --- a/README.pl.md +++ b/README.pl.md @@ -44,6 +44,7 @@ z nowszej paczki. Nie trzeba zamykać Claude'a: `xpplens.exe` używany przez otw (usuwaną później), więc ta sesja działa dalej na starej wersji, a nowe sesje startują już na nowej. Na końcu instalator proponuje ponowne uruchomienie Claude Desktop (`xpplens update --install --restart-claude` albo `install.ps1 -RestartClaude` — bez pytania); w Claude Code to samo dla jednej sesji daje `/mcp` → reconnect xpp-lens. +Wydania testowe (`X.Y.Z-beta.N`) są proponowane tylko z `--pre`: `xpplens update --install --pre`. Przydatne parametry: @@ -256,16 +257,20 @@ w `QueryTests.cs`, który woła zapytanie i sprawdza tekst odpowiedzi. Uruchamia ### Wydanie nowej wersji -Dopisz w `CHANGELOG.md` sekcję `## X.Y.Z — unreleased`, potem: +Praca idzie na gałęzi `dev`; `main` zawsze zawiera ostatnie zwykłe wydanie. Dopisz w `CHANGELOG.md` sekcję +`## X.Y.Z — unreleased`, potem na `dev`: ```powershell -.\release.ps1 -Version X.Y.Z # wszystko poniżej, staje na pierwszym błędzie +.\release.ps1 -Version X.Y.Z -Pre # wydanie testowe X.Y.Z-beta.N (pre-release; xpplens update --install --pre) +.\release.ps1 -Version X.Y.Z # zwykłe wydanie: pull request dev -> main, scalenie, tag, publikacja .\release.ps1 -Version X.Y.Z -DryRun # tylko kontrole, skan, build i testy ``` -Skrypt wstawia datę w sekcji, ustawia ``, skanuje wszystkie pliki pod nazwy prywatne, buduje i testuje, -robi commit i push `main`, czeka na CI, zakłada tag, czeka na workflow Release i sprawdza opublikowaną paczkę -(SHA-256, układ). Nazwy prywatne (klienci, partnerzy, ludzie) pochodzą z lokalnej listy, która nigdy nie trafia do +Oba warianty stają na pierwszym błędzie. Ustawiają ``, skanują wszystkie pliki pod nazwy prywatne, +budują i testują, robią commit i push `dev` i czekają na CI. Wydanie testowe dostaje tag na `dev`; zwykłe wstawia +datę w sekcji CHANGELOG, scala `dev` do `main` przez pull request (zwykłe scalanie) i dostaje tag na `main`, po czym +`dev` przesuwa się do `main`. Oba czekają na workflow Release i sprawdzają opublikowaną paczkę (SHA-256, układ, +rodzaj wydania). Nazwy prywatne (klienci, partnerzy, ludzie) pochodzą z lokalnej listy, która nigdy nie trafia do repozytorium: `-ScanList `, zmienna `XPPLENS_SCAN_LIST` albo `%USERPROFILE%\.claude\skills\xpp-lens-release\scan-names.txt` — jedno wyrażenie regularne w linii, `!słowo` dla dozwolonych słów. Bez listy wydanie się zatrzymuje. diff --git a/release-notes.ps1 b/release-notes.ps1 index 7962000..0ce5507 100644 --- a/release-notes.ps1 +++ b/release-notes.ps1 @@ -21,11 +21,14 @@ $ErrorActionPreference = 'Stop' $changelog = [IO.File]::ReadAllText((Join-Path $PSScriptRoot 'CHANGELOG.md'), [Text.Encoding]::UTF8) -replace "`r`n", "`n" $lines = $changelog -split "`n" +# A test release (1.3.0-beta.2) is described by the section of the version it leads to (## 1.3.0 - unreleased). +$isTest = $Version -match '-' +$sectionVersion = ($Version -split '-')[0] $start = -1 for ($i = 0; $i -lt $lines.Count; $i++) { - if ($lines[$i] -match ('^## ' + [regex]::Escape($Version) + '(\s|$)')) { $start = $i; break } + if ($lines[$i] -match ('^## ' + [regex]::Escape($sectionVersion) + '(\s|$)')) { $start = $i; break } } -if ($start -lt 0) { throw "CHANGELOG.md has no section '## $Version'" } +if ($start -lt 0) { throw "CHANGELOG.md has no section '## $sectionVersion'" } $end = $lines.Count for ($i = $start + 1; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^## ') { $end = $i; break } @@ -33,8 +36,18 @@ for ($i = $start + 1; $i -lt $lines.Count; $i++) { $section = if ($end - 1 -gt $start) { ($lines[($start + 1)..($end - 1)] -join "`n").Trim() } else { '' } if (-not $section) { throw "the CHANGELOG.md section for $Version is empty" } +$testNote = if ($isTest) { + @" +**Test release** of $sectionVersion, for trying it out before the regular release. ``xpplens update`` does not offer +it; install it with ``xpplens update --install --pre`` (available from 1.3.0). The regular $sectionVersion replaces it +through the usual ``xpplens update --install``. + + +"@ +} else { '' } + $notes = @" -X++ (Dynamics 365 F&O) code index served to AI assistants over MCP. +$($testNote)X++ (Dynamics 365 F&O) code index served to AI assistants over MCP. ## Install @@ -44,9 +57,9 @@ Download ``xpp-lens-$Version.zip``, unzip it and run in a regular (non-admin) Po powershell -ExecutionPolicy Bypass -File .\xpp-lens\install.ps1 -Languages en-US,pl `````` -Self-contained build - no .NET required on the target machine. Already installed? Close Claude and run +Self-contained build - no .NET required on the target machine. Already installed? Run ``xpplens update --install`` (available from 1.2.2), or ``install.ps1`` from this package - your settings and -index are kept. +index are kept, Claude may stay open (from 1.2.5). ## Changes diff --git a/release.ps1 b/release.ps1 index 74e6025..fad85e1 100644 --- a/release.ps1 +++ b/release.ps1 @@ -1,16 +1,27 @@ <# .SYNOPSIS - Releases a new xpp-lens version end to end: checks, commit, push, CI, tag, published package. + Releases xpp-lens end to end from the dev branch: a test release (-Pre) or a regular release. .DESCRIPTION - Stops at the first failure; nothing leaves the machine before the scan, the build and the tests have passed. - 1. CHANGELOG.md has a "## X.Y.Z" section; the tag vX.Y.Z does not exist yet - 2. in src/XppLens/XppLens.csproj is set to X.Y.Z - 3. private names scan of every file of the repository (see -ScanList) - 4. Release build and tests - 5. commit (when something changed) and push of main; waits for the CI workflow - 6. tag vX.Y.Z and push; waits for the Release workflow - 7. downloads the published package and checks its SHA-256 digest and its layout + Work happens on the branch dev; main always holds the last regular release. Stops at the first failure; + nothing leaves the machine before the scan, the build and the tests have passed. + + Test release (-Pre), X.Y.Z-beta.N, N counted from the existing tags: + 1. on dev, up to date; CHANGELOG.md has a "## X.Y.Z" section (may stay "unreleased"); X.Y.Z not released yet + 2. = X.Y.Z-beta.N + 3. private names scan, 4. Release build and tests + 5. commit and push of dev; waits for CI + 6. tag vX.Y.Z-beta.N; waits for the Release workflow, which publishes it as a pre-release + 7. checks the published package (SHA-256 digest, layout, marked as test release) + + Regular release, X.Y.Z: + 1. on dev, up to date and containing main; CHANGELOG section; tag free + 2. dates the CHANGELOG section ("unreleased" -> today), = X.Y.Z + 3. private names scan, 4. Release build and tests + 5. commit and push of dev; waits for CI + 6. pull request dev -> main, waits for its CI, merges it (merge commit), tags the merge on main and waits + for the Release workflow + 7. checks the published package (SHA-256 digest, layout, regular release); fast-forwards dev to main The scan list holds names that must never reach this public repository (customers, partners, people), so it is never part of the repository: a local text file, one .NET regular expression per line (case-insensitive; @@ -20,13 +31,16 @@ %USERPROFILE%\.claude\skills\xpp-lens-release\scan-names.txt. Without it the release stops. .EXAMPLE - .\release.ps1 -Version 1.2.9 + .\release.ps1 -Version 1.3.0 -Pre # test release 1.3.0-beta.N .EXAMPLE - .\release.ps1 -Version 1.2.9 -DryRun # steps 1-4 only, changes nothing + .\release.ps1 -Version 1.3.0 # regular release +.EXAMPLE + .\release.ps1 -Version 1.3.0 -DryRun # steps 1-4 only, changes nothing #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Version, + [switch] $Pre, [string] $ScanList, [switch] $DryRun, [int] $TimeoutMinutes = 30 @@ -42,40 +56,76 @@ function Native([string]$what) { if ($LASTEXITCODE -ne 0) { Fail "$what failed ( $gh = (Get-Command gh -ErrorAction SilentlyContinue).Source if (-not $gh) { $gh = Join-Path $env:ProgramFiles 'GitHub CLI\gh.exe' } if (-not (Test-Path $gh)) { Fail "GitHub CLI (gh) not found" } -$tag = "v$Version" $csproj = 'src\XppLens\XppLens.csproj' +$work = 'dev' + +# JSON from gh is filtered here, not with --jq: Windows PowerShell 5.1 drops the inner double quotes of an argument +# passed to a native program, so a jq filter like select(.headSha=="abc") would arrive broken. +function Gh-Json([string[]]$ghArgs) { + $out = & $gh @ghArgs + if ($LASTEXITCODE -ne 0) { return $null } + return (($out -join "`n") | ConvertFrom-Json) +} + +function Wait-Run([string]$workflow, [scriptblock]$match, [string]$label) { + $deadline = (Get-Date).AddMinutes($TimeoutMinutes) + $id = $null + while (-not $id -and (Get-Date) -lt $deadline) { + Start-Sleep -Seconds 5 + $runs = Gh-Json @('run', 'list', '--workflow', $workflow, '--limit', '30', '--json', 'databaseId,headSha,headBranch,event') + $id = (@($runs) | Where-Object $match | Select-Object -First 1).databaseId + } + if (-not $id) { Fail "$label run did not start within $TimeoutMinutes minutes" } + Ok "$label run $id ..." + & $gh run watch $id --exit-status --interval 15 | Out-Null + $conclusion = (& $gh run view $id --json conclusion --jq '.conclusion').Trim() + if ($conclusion -ne 'success') { Fail "$label run $id ended with '$conclusion' - see: gh run view $id --log-failed" } + Ok "${label}: success" +} # ---------------------------------------------------------------- 1. preconditions -Step "1/7 Checks for $tag" -if ($Version -notmatch '^\d+\.\d+\.\d+$') { Fail "version '$Version' is not X.Y.Z" } -$branch = (git rev-parse --abbrev-ref HEAD).Trim(); Native 'git rev-parse' -if ($branch -ne 'main') { Fail "on branch '$branch', releases are made from main" } +if ($Version -notmatch '^\d+\.\d+\.\d+$') { Fail "version '$Version' is not X.Y.Z (a test release adds -Pre, not a suffix)" } git fetch --quiet --tags origin; Native 'git fetch' -if (git tag --list $tag) { Fail "tag $tag already exists - published versions cannot change; use the next version" } -$behind = (git rev-list --count 'HEAD..origin/main').Trim() -if ([int]$behind -gt 0) { Fail "main is $behind commit(s) behind origin/main - pull first" } +$finalTag = "v$Version" +if (git tag --list $finalTag) { Fail "$Version is already released (tag $finalTag) - published versions cannot change; use the next version" } +if ($Pre) { + $n = 1 + (@(git tag --list "v$Version-beta.*") | ForEach-Object { if ($_ -match '-beta\.(\d+)$') { [int]$Matches[1] } } | Measure-Object -Maximum).Maximum + $fullVersion = "$Version-beta.$n" +} +else { $fullVersion = $Version } +$tag = "v$fullVersion" +Step "1/7 Checks for $tag$(if ($Pre) { ' (test release)' })" + +$branch = (git rev-parse --abbrev-ref HEAD).Trim(); Native 'git rev-parse' +if ($branch -ne $work) { Fail "on branch '$branch' - releases are made from $work (git switch $work)" } +$behind = (git rev-list --count "HEAD..origin/$work").Trim() +if ([int]$behind -gt 0) { Fail "$work is $behind commit(s) behind origin/$work - pull first" } +if (-not $Pre) { + $missing = (git rev-list --count 'HEAD..origin/main').Trim() + if ([int]$missing -gt 0) { Fail "$work lacks $missing commit(s) of main - merge origin/main into $work first" } +} $changelog = [IO.File]::ReadAllText((Resolve-Path 'CHANGELOG.md')) -$section = [regex]::Match($changelog, "(?ms)^## $([regex]::Escape($Version))\b.*?(?=^## |\z)") +$sectionRx = "(?ms)^## $([regex]::Escape($Version))\b.*?(?=^## |\z)" +$section = [regex]::Match($changelog, $sectionRx) if (-not $section.Success) { Fail "CHANGELOG.md has no '## $Version' section" } -Ok "CHANGELOG section found, tag is free, main is up to date" +Ok "CHANGELOG section found, tag is free, $work is up to date" # ---------------------------------------------------------------- 2. version -Step "2/7 = $Version" -# "## X.Y.Z — unreleased" gets today's date. -if ($section.Value -match "^## $([regex]::Escape($Version))\b[^\r\n]*unreleased") { +Step "2/7 = $fullVersion" +# A regular release dates "## X.Y.Z - unreleased"; a test release leaves it undated. +if (-not $Pre -and $section.Value -match "^## $([regex]::Escape($Version))\b[^\r\n]*unreleased") { if ($DryRun) { Ok "would date the CHANGELOG section (dry run)" } else { $dated = [regex]::Replace($changelog, "(?m)^(## $([regex]::Escape($Version))\b[^\r\n]*?)unreleased", "`${1}$(Get-Date -Format 'yyyy-MM-dd')") [IO.File]::WriteAllText((Resolve-Path 'CHANGELOG.md'), $dated, (New-Object Text.UTF8Encoding($false))) - $changelog = $dated - $section = [regex]::Match($changelog, "(?ms)^## $([regex]::Escape($Version))\b.*?(?=^## |\z)") + $section = [regex]::Match($dated, $sectionRx) Ok "CHANGELOG section dated" } } $projText = [IO.File]::ReadAllText((Resolve-Path $csproj)) -$newProj = [regex]::Replace($projText, '[^<]*', "$Version") -if ($newProj -eq $projText) { Ok "already $Version" } -elseif ($DryRun) { Ok "would set $Version (dry run)" } +$newProj = [regex]::Replace($projText, '[^<]*', "$fullVersion") +if ($newProj -eq $projText) { Ok "already $fullVersion" } +elseif ($DryRun) { Ok "would set $fullVersion (dry run)" } else { [IO.File]::WriteAllText((Resolve-Path $csproj), $newProj, (New-Object Text.UTF8Encoding($false))); Ok "set" } # ---------------------------------------------------------------- 3. private names scan @@ -117,62 +167,64 @@ Ok "tests passed" if ($DryRun) { Write-Host "`nDry run: checks passed, nothing committed, pushed or tagged." -ForegroundColor Green; exit 0 } -# ---------------------------------------------------------------- 5. commit, push, CI -Step "5/7 Commit, push, CI" +# ---------------------------------------------------------------- 5. commit, push, CI on dev +Step "5/7 Commit, push $work, CI" +$title = if ($Pre) { "xpp-lens $fullVersion (test release)" } else { "xpp-lens $fullVersion" } +$body = (($section.Value -split "`n" | Select-Object -Skip 1) -join "`n").Trim() git add -A; Native 'git add' git diff --cached --quiet if ($LASTEXITCODE -ne 0) { - # Title and the release's own notes from CHANGELOG as the body. - $body = ($section.Value -split "`n" | Select-Object -Skip 1) -join "`n" $msgFile = [IO.Path]::GetTempFileName() - [IO.File]::WriteAllText($msgFile, "xpp-lens $Version`n`n$($body.Trim())`n", (New-Object Text.UTF8Encoding($false))) + [IO.File]::WriteAllText($msgFile, "$title`n`n$body`n", (New-Object Text.UTF8Encoding($false))) git commit --quiet -F $msgFile; Native 'git commit' Remove-Item $msgFile Ok "committed" } else { Ok "nothing to commit" } -git push --quiet origin main; Native 'git push' +git push --quiet origin $work; Native 'git push' $sha = (git rev-parse HEAD).Trim() Ok "pushed $sha" +Wait-Run 'ci.yml' { $_.headSha -eq $sha -and $_.event -eq 'push' } 'CI' -# JSON from gh is filtered here, not with --jq: Windows PowerShell 5.1 drops the inner double quotes of an argument -# passed to a native program, so a jq filter like select(.headSha=="abc") would arrive broken. -function Gh-Json([string[]]$ghArgs) { - $out = & $gh @ghArgs - if ($LASTEXITCODE -ne 0) { return $null } - return (($out -join "`n") | ConvertFrom-Json) +# ---------------------------------------------------------------- 6. (merge to main,) tag, release workflow +if ($Pre) { + Step "6/7 Tag $tag on $work, Release workflow" + $tagSha = $sha } - -function Wait-Run([string]$workflow, [scriptblock]$match, [string]$label) { - $deadline = (Get-Date).AddMinutes($TimeoutMinutes) - $id = $null - while (-not $id -and (Get-Date) -lt $deadline) { - Start-Sleep -Seconds 5 - $runs = Gh-Json @('run', 'list', '--workflow', $workflow, '--limit', '20', '--json', 'databaseId,headSha,headBranch') - $id = (@($runs) | Where-Object $match | Select-Object -First 1).databaseId +else { + Step "6/7 Pull request $work -> main, merge, tag $tag, Release workflow" + $open = Gh-Json @('pr', 'list', '--base', 'main', '--head', $work, '--state', 'open', '--json', 'number') + $pr = (@($open) | Select-Object -First 1).number + if (-not $pr) { + $bodyFile = [IO.Path]::GetTempFileName() + [IO.File]::WriteAllText($bodyFile, "$body`n", (New-Object Text.UTF8Encoding($false))) + & $gh pr create --base main --head $work --title $title --body-file $bodyFile | Out-Null; Native 'gh pr create' + Remove-Item $bodyFile + $pr = (@(Gh-Json @('pr', 'list', '--base', 'main', '--head', $work, '--state', 'open', '--json', 'number')) | Select-Object -First 1).number + if (-not $pr) { Fail "the pull request $work -> main was not found after creating it" } + Ok "pull request #$pr" } - if (-not $id) { Fail "$label run did not start within $TimeoutMinutes minutes" } - Ok "$label run $id ..." - & $gh run watch $id --exit-status --interval 15 | Out-Null - $conclusion = (& $gh run view $id --json conclusion --jq '.conclusion').Trim() - if ($conclusion -ne 'success') { Fail "$label run $id ended with '$conclusion' - see: gh run view $id --log-failed" } - Ok "${label}: success" + else { Ok "pull request #$pr (already open)" } + Wait-Run 'ci.yml' { $_.headSha -eq $sha -and $_.event -eq 'pull_request' } 'PR CI' + & $gh pr merge $pr --merge --subject $title | Out-Null; Native 'gh pr merge' + $merged = Gh-Json @('pr', 'view', "$pr", '--json', 'mergeCommit,state') + if ($merged.state -ne 'MERGED' -or -not $merged.mergeCommit.oid) { Fail "pull request #$pr is not merged (state $($merged.state))" } + $tagSha = $merged.mergeCommit.oid + git fetch --quiet origin; Native 'git fetch' + Ok "merged into main as $tagSha" } -Wait-Run 'ci.yml' { $_.headSha -eq $sha } 'CI' - -# ---------------------------------------------------------------- 6. tag, release workflow -Step "6/7 Tag $tag, Release workflow" -git tag $tag $sha; Native 'git tag' +git tag $tag $tagSha; Native 'git tag' git push --quiet origin $tag; Native 'git push tag' Wait-Run 'release.yml' { $_.headBranch -eq $tag } 'Release' # ---------------------------------------------------------------- 7. published package Step "7/7 Published package" -$asset = "xpp-lens-$Version.zip" -$release = Gh-Json @('release', 'view', $tag, '--json', 'assets') +$asset = "xpp-lens-$fullVersion.zip" +$release = Gh-Json @('release', 'view', $tag, '--json', 'assets,isPrerelease,url') $digest = (@($release.assets) | Where-Object { $_.name -eq $asset } | Select-Object -First 1).digest if (-not $digest) { Fail "release $tag has no $asset" } -$tmp = Join-Path ([IO.Path]::GetTempPath()) "xpp-lens-release-check-$Version" +if ([bool]$release.isPrerelease -ne [bool]$Pre) { Fail "release $tag is $(if ($release.isPrerelease) { 'a test release' } else { 'a regular release' }) - expected the other kind" } +$tmp = Join-Path ([IO.Path]::GetTempPath()) "xpp-lens-release-check-$fullVersion" if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force } New-Item -ItemType Directory $tmp | Out-Null & $gh release download $tag --pattern $asset --dir $tmp; Native 'gh release download' @@ -187,8 +239,15 @@ Remove-Item $tmp -Recurse -Force foreach ($e in 'xpp-lens/install.ps1', 'xpp-lens/bin/xpplens.exe', 'xpp-lens/CHANGELOG.md') { if ($entries -notcontains $e) { Fail "$asset has no $e (entries: $($entries -join ', '))" } } -Ok "${asset}: SHA-256 matches, layout ok" +Ok "${asset}: SHA-256 matches, layout ok, $(if ($Pre) { 'test release' } else { 'regular release' })" + +if (-not $Pre) { + # main = dev + the merge commit: dev moves forward to it, so the next pull request starts clean. + git merge --quiet --ff-only origin/main + if ($LASTEXITCODE -eq 0) { git push --quiet origin $work; Ok "$work fast-forwarded to main" } + else { Write-Host " NOTE: $work could not be fast-forwarded to main - merge origin/main into $work by hand" -ForegroundColor Yellow } +} -$url = (& $gh release view $tag --json url --jq '.url').Trim() -Write-Host "`nReleased xpp-lens ${Version}: $url" -ForegroundColor Green -Write-Host "Install: xpplens update --install" +Write-Host "`nReleased xpp-lens ${fullVersion}: $($release.url)" -ForegroundColor Green +if ($Pre) { Write-Host "Install on a test machine: xpplens update --install --pre" } +else { Write-Host "Install: xpplens update --install" } diff --git a/src/XppLens/CommandLine.cs b/src/XppLens/CommandLine.cs index 4b3b331..1b805ad 100644 --- a/src/XppLens/CommandLine.cs +++ b/src/XppLens/CommandLine.cs @@ -11,7 +11,7 @@ public sealed partial class CommandLine // "name=" takes a value, "name" is a switch. static readonly Dictionary Known = new(StringComparer.OrdinalIgnoreCase) { - ["update"] = ["install", "restart-claude"], + ["update"] = ["install", "restart-claude", "pre"], ["migrate"] = ["from=", "config="], ["stats"] = ["days=", "top="], ["mcp"] = [], diff --git a/src/XppLens/Program.cs b/src/XppLens/Program.cs index 327d555..dd98e18 100644 --- a/src/XppLens/Program.cs +++ b/src/XppLens/Program.cs @@ -21,7 +21,7 @@ string Arg(int i) => i < rest.Count ? rest[i] : throw new ArgumentException($"missing argument #{i + 1} for '{cmd}'"); -static string AppVersion() => typeof(Config).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"; +static string AppVersion() => Updater.Current.ToString(); // Box-drawing frames on a console (UTF-8 for the time of writing them); +-| when redirected to a file or pipe. static void PrintTables(Func render, bool ascii) @@ -60,7 +60,7 @@ static void PrintTables(Func render, bool ascii) } if (cmd == "update") - return await Updater.Run(install: Flag("install"), restartClaude: Flag("restart-claude")); + return await Updater.Run(install: Flag("install"), restartClaude: Flag("restart-claude"), pre: Flag("pre")); if (cmd == "migrate") { @@ -277,10 +277,10 @@ xpplens build [--full-only] [--std-only] [--compiled-only] [--force] build / u (--std-only also switches indexStandard on when an installation was made without it) xpplens status [--counts] [--ascii] --counts: also count references and labels (slow on a cold disk) --ascii: frames from +-| (automatic when redirected) - xpplens update [--install [--restart-claude]] check GitHub for a newer release; --install downloads, + xpplens update [--install [--restart-claude]] [--pre] check GitHub for a newer release; --install downloads, verifies (SHA-256) and installs it, keeping settings and index (Claude may stay open; --restart-claude restarts Claude Desktop - at the end without asking) + at the end without asking); --pre: test releases too xpplens stats [--days 7] [--top 10] tokens, time and empty answers of MCP calls xpplens find [--kind any|object|method|field] [--type t] [--model m] [--limit n] [--property "Name[;Name2]" | "Name=*value*"] list root properties of the matching objects diff --git a/src/XppLens/Updater.cs b/src/XppLens/Updater.cs index bbb86c8..d4770dd 100644 --- a/src/XppLens/Updater.cs +++ b/src/XppLens/Updater.cs @@ -12,28 +12,88 @@ namespace XppLens; /// SHA-256 digest GitHub publishes for it and runs its install.ps1 against this installation. This is the only /// network access of xpp-lens and happens only when the command is run. /// +/// +/// A release version: "1.3.0", or a test release "1.3.0-beta.2". A test release comes before its final version +/// (1.3.0-beta.2 < 1.3.0); its parts after the dash compare as numbers where they are numbers (beta.10 > beta.9). +/// +public sealed record ReleaseVersion(int Major, int Minor, int Patch, string? Pre = null) : IComparable +{ + public bool IsPrerelease => Pre != null; + + public int CompareTo(ReleaseVersion? other) + { + if (other is null) return 1; + int c = Major.CompareTo(other.Major); + if (c == 0) c = Minor.CompareTo(other.Minor); + if (c == 0) c = Patch.CompareTo(other.Patch); + if (c != 0) return c; + if (Pre == null || other.Pre == null) return Pre == null ? (other.Pre == null ? 0 : 1) : -1; + var a = Pre.Split('.'); + var b = other.Pre.Split('.'); + for (int i = 0; i < Math.Min(a.Length, b.Length); i++) + { + bool an = int.TryParse(a[i], out var ai), bn = int.TryParse(b[i], out var bi); + c = an && bn ? ai.CompareTo(bi) : an ? -1 : bn ? 1 : string.CompareOrdinal(a[i], b[i]); + if (c != 0) return c; + } + return a.Length.CompareTo(b.Length); + } + + public static bool operator >(ReleaseVersion? a, ReleaseVersion? b) => a is not null && a.CompareTo(b) > 0; + public static bool operator <(ReleaseVersion? a, ReleaseVersion? b) => b is not null && b.CompareTo(a) > 0; + public static bool operator >=(ReleaseVersion? a, ReleaseVersion? b) => !(a < b); + public static bool operator <=(ReleaseVersion? a, ReleaseVersion? b) => !(a > b); + + public override string ToString() => $"{Major}.{Minor}.{Patch}{(Pre != null ? "-" + Pre : "")}"; +} + public static class Updater { public const string Repository = "willowdev-code/xpp-lens"; - public sealed record ReleaseInfo(Version Version, string Tag, string PageUrl, string? AssetName, string? AssetUrl, string? Sha256, string Notes); + public sealed record ReleaseInfo(ReleaseVersion Version, string Tag, string PageUrl, string? AssetName, string? AssetUrl, string? Sha256, + string Notes, bool Prerelease = false); - public static Version Current => typeof(Config).Assembly.GetName().Version is { } v ? new Version(v.Major, v.Minor, Math.Max(0, v.Build)) : new Version(0, 0, 0); + /// + /// The running version, with a test-release suffix ("1.3.0-beta.1") — from the informational version, which keeps + /// it (the assembly version holds numbers only); "+commit" build metadata is dropped. + /// + public static ReleaseVersion Current { get; } = + ParseVersion(typeof(Config).Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType().FirstOrDefault()?.InformationalVersion) + ?? (typeof(Config).Assembly.GetName().Version is { } v ? new ReleaseVersion(v.Major, v.Minor, Math.Max(0, v.Build)) : new ReleaseVersion(0, 0, 0)); - /// "v1.2.10" / "1.2.10" → 1.2.10. - public static Version? ParseVersion(string? tag) + /// "v1.2.10" / "1.2.10" → 1.2.10; "v1.3.0-beta.2" → 1.3.0-beta.2; "+…" build metadata is dropped. + public static ReleaseVersion? ParseVersion(string? tag) { if (string.IsNullOrWhiteSpace(tag)) return null; var t = tag.Trim().TrimStart('v', 'V'); - int dash = t.IndexOfAny(['-', '+']); - if (dash > 0) t = t[..dash]; - return Version.TryParse(t, out var v) ? new Version(v.Major, v.Minor, Math.Max(0, v.Build)) : null; + if (t.IndexOf('+') is var plus and >= 0) t = t[..plus]; + string? pre = null; + if (t.IndexOf('-') is var dash and > 0) + { + pre = t[(dash + 1)..]; + t = t[..dash]; + if (pre.Length == 0) pre = null; + } + return Version.TryParse(t, out var v) ? new ReleaseVersion(v.Major, v.Minor, Math.Max(0, v.Build), pre) : null; } /// Reads the JSON of GET /repos/{repo}/releases/latest. - public static ReleaseInfo? ParseRelease(string json) + public static ReleaseInfo? ParseRelease(string json) => ParseRelease(JsonNode.Parse(json)); + + /// + /// The newest release in the JSON of GET /repos/{repo}/releases, test releases included (drafts are skipped). + /// + public static ReleaseInfo? NewestRelease(string json) => + (JsonNode.Parse(json) as JsonArray)?.OfType() + .Where(r => r["draft"]?.GetValue() != true) + .Select(r => ParseRelease(r)) + .OfType() + .MaxBy(r => r.Version); + + static ReleaseInfo? ParseRelease(JsonNode? n) { - var n = JsonNode.Parse(json); var tag = n?["tag_name"]?.GetValue(); if (ParseVersion(tag) is not { } version) return null; var asset = (n!["assets"] as JsonArray)?.OfType() @@ -44,7 +104,7 @@ public sealed record ReleaseInfo(Version Version, string Tag, string PageUrl, st return new ReleaseInfo(version, tag!, n["html_url"]?.GetValue() ?? $"https://github.com/{Repository}/releases", asset?["name"]?.GetValue(), asset?["browser_download_url"]?.GetValue(), digest != null && digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? digest[7..].ToLowerInvariant() : null, - n["body"]?.GetValue() ?? ""); + n["body"]?.GetValue() ?? "", n["prerelease"]?.GetValue() ?? version.IsPrerelease); } static string StateDir => @@ -133,13 +193,17 @@ public static string InstallDir() } /// Passed to the installer: restart Claude Desktop when done, without asking. - public static async Task Run(bool install, bool restartClaude = false) + /// Also consider test releases (e.g. 1.3.0-beta.1); without it only regular releases count. + public static async Task Run(bool install, bool restartClaude = false, bool pre = false) { using var http = Client(); ReleaseInfo? latest; try { - latest = ParseRelease(await http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases/latest")); + // /releases/latest never returns a test release; the list holds both kinds. + latest = pre + ? NewestRelease(await http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases?per_page=30")) + : ParseRelease(await http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases/latest")); } catch (Exception ex) { @@ -151,13 +215,14 @@ public static async Task Run(bool install, bool restartClaude = false) Console.Error.WriteLine("the latest release has no readable version tag"); return 1; } - SaveState(latest); + // 'status' reports regular releases only, so a test release found with --pre is not remembered. + if (!latest.Prerelease) SaveState(latest); Console.WriteLine($"installed: {Current}"); - Console.WriteLine($"latest: {latest.Version} {latest.PageUrl}"); + Console.WriteLine($"latest: {latest.Version}{(latest.Prerelease ? " (test release)" : "")} {latest.PageUrl}"); if (latest.Version <= Current) { - Console.WriteLine("you have the latest version."); + Console.WriteLine(pre ? "you have the latest version (test releases included)." : "you have the latest version."); return 0; } if (!install) @@ -168,7 +233,7 @@ public static async Task Run(bool install, bool restartClaude = false) Console.WriteLine("\nchanges:"); foreach (var l in notes) Console.WriteLine(" " + l.Trim()); } - Console.WriteLine("\ninstall it with: xpplens update --install (Claude may stay open; your settings and index are kept)"); + Console.WriteLine($"\ninstall it with: xpplens update --install{(pre ? " --pre" : "")} (Claude may stay open; your settings and index are kept)"); return 0; } diff --git a/src/XppLens/XppLens.csproj b/src/XppLens/XppLens.csproj index 32f6bf0..175aa9b 100644 --- a/src/XppLens/XppLens.csproj +++ b/src/XppLens/XppLens.csproj @@ -20,7 +20,7 @@ xpp-lens — X++ code index for MCP X++ (Dynamics 365 F&O) code index served to AI assistants over MCP Copyright © 2026 WillowDev - 1.2.9 + 1.3.0-beta.1 diff --git a/tests/XppLens.Tests/MigrationTests.cs b/tests/XppLens.Tests/MigrationTests.cs index 2aca5f7..4a4ad3b 100644 --- a/tests/XppLens.Tests/MigrationTests.cs +++ b/tests/XppLens.Tests/MigrationTests.cs @@ -141,16 +141,56 @@ public class UpdaterTests public void Versions_compare_numerically() { Assert.True(Updater.ParseVersion("v1.2.10") > Updater.ParseVersion("1.2.9")); - Assert.Equal(new Version(1, 2, 2), Updater.ParseVersion("v1.2.2-beta")); - Assert.Equal(new Version(1, 2, 0), Updater.ParseVersion("1.2")); + Assert.Equal(new ReleaseVersion(1, 2, 2, "beta"), Updater.ParseVersion("v1.2.2-beta")); + Assert.Equal(new ReleaseVersion(1, 2, 0), Updater.ParseVersion("1.2")); + Assert.Equal(new ReleaseVersion(1, 3, 0), Updater.ParseVersion("1.3.0+5f2a9c1")); Assert.Null(Updater.ParseVersion("latest")); } + [Fact] + public void Test_releases_come_before_their_final_version() + { + ReleaseVersion V(string s) => Updater.ParseVersion(s)!; + Assert.True(V("1.3.0-beta.1") < V("1.3.0")); + Assert.True(V("1.3.0-beta.1") > V("1.2.9")); + Assert.True(V("1.3.0-beta.10") > V("1.3.0-beta.9")); + Assert.True(V("1.3.0-beta.2") > V("1.3.0-beta.1")); + Assert.Equal("1.3.0-beta.2", V("v1.3.0-beta.2").ToString()); + Assert.True(V("1.3.0-beta.2").IsPrerelease); + Assert.Equal(0, V("1.3.0").CompareTo(V("v1.3.0"))); + } + + [Fact] + public void Newest_release_includes_test_releases_but_not_drafts() + { + const string list = """ + [ + { "tag_name": "v1.2.9", "prerelease": false, "assets": [] }, + { "tag_name": "v1.3.0-beta.2", "prerelease": true, "assets": [] }, + { "tag_name": "v1.3.0-beta.1", "prerelease": true, "assets": [] }, + { "tag_name": "v1.4.0", "draft": true, "assets": [] } + ] + """; + var newest = Updater.NewestRelease(list)!; + Assert.Equal("v1.3.0-beta.2", newest.Tag); + Assert.True(newest.Prerelease); + + var withFinal = list.Replace("[\n", "[\n { \"tag_name\": \"v1.3.0\", \"prerelease\": false, \"assets\": [] },\n").Replace("[\r\n", "[\r\n { \"tag_name\": \"v1.3.0\", \"prerelease\": false, \"assets\": [] },\r\n"); + Assert.Equal("v1.3.0", Updater.NewestRelease(withFinal)!.Tag); + } + + [Fact] + public void Running_version_has_no_build_metadata() + { + Assert.DoesNotContain("+", Updater.Current.ToString()); + Assert.True(Updater.Current > new ReleaseVersion(1, 0, 0)); + } + [Fact] public void Release_json_gives_package_and_digest() { var r = Updater.ParseRelease(Release)!; - Assert.Equal(new Version(1, 3, 0), r.Version); + Assert.Equal(new ReleaseVersion(1, 3, 0), r.Version); Assert.Equal("xpp-lens-1.3.0.zip", r.AssetName); Assert.Equal("https://example.invalid/xpp-lens-1.3.0.zip", r.AssetUrl); Assert.Equal("abcdef0123", r.Sha256); From 3229817cd381951d80bea3e712455d0f1b723809 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:57:54 +0200 Subject: [PATCH 2/4] Setup program (Inno Setup) and updates through it - packaging/inno/xpp-lens.iss: wizard (PackagesLocalDirectory, label languages, standard models), PATH, Start menu, uninstall; takes over C:\Tools\xpp-lens - xpplens update --install runs the setup program silently when the release has one - restart-claude.ps1 shared by install.ps1 and the setup program - CI builds the setup program on every push; the Release workflow publishes it --- .github/install-inno.ps1 | 11 + .github/workflows/ci.yml | 23 ++ .github/workflows/release.yml | 10 +- CHANGELOG.md | 9 + README.md | 20 +- README.pl.md | 20 +- install.ps1 | 52 +--- pack.ps1 | 35 ++- packaging/inno/xpp-lens.iss | 380 ++++++++++++++++++++++++++ release-notes.ps1 | 10 +- release.ps1 | 11 + restart-claude.ps1 | 67 +++++ src/XppLens/Updater.cs | 64 ++++- tests/XppLens.Tests/MigrationTests.cs | 15 + 14 files changed, 644 insertions(+), 83 deletions(-) create mode 100644 .github/install-inno.ps1 create mode 100644 packaging/inno/xpp-lens.iss create mode 100644 restart-claude.ps1 diff --git a/.github/install-inno.ps1 b/.github/install-inno.ps1 new file mode 100644 index 0000000..70ebc3f --- /dev/null +++ b/.github/install-inno.ps1 @@ -0,0 +1,11 @@ +# Makes sure Inno Setup 6 (ISCC.exe) is available on a GitHub runner: uses the preinstalled one, else installs it. +$ErrorActionPreference = 'Stop' +$known = @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe") +$iscc = $known | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $iscc) { + choco install innosetup --no-progress -y + if ($LASTEXITCODE -ne 0) { throw 'installing Inno Setup failed' } + $iscc = $known | Where-Object { Test-Path $_ } | Select-Object -First 1 +} +if (-not $iscc) { throw 'Inno Setup 6 (ISCC.exe) not found after installing' } +Write-Host "Inno Setup: $iscc" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3f926e..6039151 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,26 @@ jobs: - name: Test run: dotnet test xpp-lens.sln -c Release --no-build --logger "console;verbosity=normal" + + # The setup program is built on every push, so a broken Inno script shows up before a release. + installer: + name: installer + runs-on: windows-latest + timeout-minutes: 20 + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 9.0.x + + - name: Inno Setup + shell: pwsh + run: ./.github/install-inno.ps1 + + - name: Package and setup program + shell: powershell + run: .\pack.ps1 -OutDir dist -Installer diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4c02ed..b8f0d20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,9 +41,13 @@ jobs: shell: powershell run: .\release-notes.ps1 -Version $env:VERSION -OutFile dist\notes.md - - name: Package + - name: Inno Setup + shell: pwsh + run: ./.github/install-inno.ps1 + + - name: Package and setup program shell: powershell - run: .\pack.ps1 -OutDir dist + run: .\pack.ps1 -OutDir dist -Installer # vX.Y.Z-beta.N is a test release: marked as pre-release, never "Latest", so 'xpplens update' does not offer it # ('xpplens update --pre' does). @@ -53,4 +57,4 @@ jobs: GH_TOKEN: ${{ github.token }} run: | $kind = if ($env:TAG -match '-') { '--prerelease' } else { '--latest' } - gh release create $env:TAG "dist/xpp-lens-$env:VERSION.zip" --title "xpp-lens $env:VERSION" --notes-file dist/notes.md --verify-tag $kind + gh release create $env:TAG "dist/xpp-lens-$env:VERSION.zip" "dist/xpp-lens-setup-$env:VERSION.exe" --title "xpp-lens $env:VERSION" --notes-file dist/notes.md --verify-tag $kind diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb298a..16c1b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to xpp-lens (called xpp-graft up to 1.2.0). Versions follow ## 1.3.0 — unreleased ### Added +- Setup program `xpp-lens-setup-X.Y.Z.exe` (Inno Setup): a wizard in Polish or English for the current user, no + administrator rights. It finds PackagesLocalDirectory (`xpplens detect`, or choose the folder), asks for the label + languages and whether to index the Microsoft standard, registers xpp-lens in Claude Desktop and Claude Code and + offers to build the index. It puts `xpplens` on the user's PATH, adds Start menu entries (status, update the index, + check for updates, uninstall) and an entry in Windows' installed apps; uninstalling asks whether to remove the index. + An installation made by `install.ps1` (`C:\Tools\xpp-lens`) is taken over in place with its settings and index. +- `xpplens update --install` runs the setup program of the release silently into the current installation (SHA-256 + checked, `--restart-claude` passed on); releases without one still go through the ZIP and `install.ps1`. +- `restart-claude.ps1`: the Claude Desktop restart of the installers, shared by `install.ps1` and the setup program. - Test releases: a version can first be published as `X.Y.Z-beta.N`, marked as a pre-release on GitHub. `xpplens update` never offers it; `xpplens update --install --pre` installs the newest release including test releases, so a new version can be tried on one machine before everyone gets it. The regular `X.Y.Z` later diff --git a/README.md b/README.md index e6851c9..caaab5f 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,17 @@ An independent project, inspired by [Graft](https://github.com/trailhq/Graft) (a ## Installing on a new machine -The install package is not part of the repository (`dist\` is excluded by `.gitignore`). There are two ways: +The install package is not part of the repository (`dist\` is excluded by `.gitignore`). There are three ways: -**A. From a release package** — nothing needs to be installed on the target machine, not even .NET. +**A. Setup program** (from 1.3.0) — download `xpp-lens-setup-X.Y.Z.exe` from the **Releases** tab and run it. A +wizard in Polish or English, for the current user, no administrator rights: it finds `PackagesLocalDirectory`, asks for +the label languages and whether to index the Microsoft standard, registers xpp-lens in Claude and offers to build the +index. It puts `xpplens` on your PATH, adds Start menu entries (status, update the index, check for updates) and an +entry in Windows' installed apps. An installation made by `install.ps1` in `C:\Tools\xpp-lens` is taken over in place, +with its settings and index. The program is not signed yet: when Windows shows "Windows protected your PC", choose +"More info" → "Run anyway". + +**B. From the release ZIP** — nothing needs to be installed on the target machine, not even .NET. 1. Download `xpp-lens-X.Y.Z.zip` from the **Releases** tab of this repository. 2. Unzip it and run in a regular PowerShell window (not as administrator): @@ -24,12 +32,12 @@ The install package is not part of the repository (`dist\` is excluded by `.giti powershell -ExecutionPolicy Bypass -File .\xpp-lens\install.ps1 -Languages en-US,pl ``` -**B. From source** — requires the .NET 9 SDK. +**C. From source** — requires the .NET 9 SDK (and Inno Setup 6 for the setup program). ```powershell git clone https://github.com/willowdev-code/xpp-lens.git C:\Dev\xpp-lens cd C:\Dev\xpp-lens -.\pack.ps1 # creates dist\xpp-lens\ and dist\xpp-lens-X.Y.Z.zip +.\pack.ps1 # dist\xpp-lens\, dist\xpp-lens-X.Y.Z.zip (and the setup program when Inno Setup is there) powershell -ExecutionPolicy Bypass -File .\dist\xpp-lens\install.ps1 -Languages en-US,pl ``` @@ -39,8 +47,8 @@ builds the index (custom models take about a minute, the Microsoft standard 20 Claude may stay open: start a new session afterwards (or restart Claude Desktop) to load the server. Updating an installation: `xpplens update` shows whether a newer release exists; `xpplens update --install` downloads -it (SHA-256 checked) and runs its installer — settings and index are kept. Running `install.ps1` from a newer package -does the same. Claude does not have to be closed: an `xpplens.exe` used by an open session is renamed to `*.old` +it (SHA-256 checked) and runs its installer — from 1.3.0 the setup program, silently, into the current installation; +settings and index are kept. Running the setup program or `install.ps1` from a newer package does the same. Claude does not have to be closed: an `xpplens.exe` used by an open session is renamed to `*.old` (deleted later), so that session keeps the old version and new sessions start the new one. At the end the installer offers to restart Claude Desktop (`xpplens update --install --restart-claude` or `install.ps1 -RestartClaude`: without asking); in Claude Code `/mcp` → reconnect xpp-lens does the same for one session. Test releases (`X.Y.Z-beta.N`) diff --git a/README.pl.md b/README.pl.md index c446ac6..d9bf7cc 100644 --- a/README.pl.md +++ b/README.pl.md @@ -13,9 +13,17 @@ Niezależny projekt inspirowany [Graftem](https://github.com/trailhq/Graft) (gra ## Instalacja na nowej maszynie -Paczki instalacyjnej nie ma w repozytorium (`dist\` jest pomijany przez `.gitignore`). Są dwie drogi: +Paczki instalacyjnej nie ma w repozytorium (`dist\` jest pomijany przez `.gitignore`). Są trzy drogi: -**A. Z gotowej paczki** — na maszynie docelowej nie trzeba niczego instalować, nawet .NET. +**A. Instalator** (od 1.3.0) — pobierz `xpp-lens-setup-X.Y.Z.exe` z zakładki **Releases** i uruchom. Kreator po polsku +albo po angielsku, dla bieżącego użytkownika, bez uprawnień administratora: wykrywa `PackagesLocalDirectory`, pyta +o języki etykiet i o indeksowanie standardu Microsoftu, rejestruje xpp-lens w Claude i proponuje zbudowanie indeksu. +Dopisuje `xpplens` do PATH, dodaje skróty w menu Start (status, aktualizacja indeksu, sprawdzenie aktualizacji) i wpis +w „Aplikacjach” Windows. Instalację zrobioną przez `install.ps1` w `C:\Tools\xpp-lens` przejmuje w miejscu, razem +z ustawieniami i indeksem. Program nie jest jeszcze podpisany: gdy Windows pokaże „System Windows ochronił komputer”, +wybierz „Więcej informacji” → „Uruchom mimo to”. + +**B. Z paczki ZIP** — na maszynie docelowej nie trzeba niczego instalować, nawet .NET. 1. Pobierz `xpp-lens-X.Y.Z.zip` z zakładki **Releases** tego repozytorium. 2. Rozpakuj i w zwykłym PowerShellu (nie jako administrator) uruchom: @@ -24,12 +32,12 @@ Paczki instalacyjnej nie ma w repozytorium (`dist\` jest pomijany przez `.gitign powershell -ExecutionPolicy Bypass -File .\xpp-lens\install.ps1 -Languages en-US,pl ``` -**B. Z kodu źródłowego** — wymaga .NET 9 SDK. +**C. Z kodu źródłowego** — wymaga .NET 9 SDK (i Inno Setup 6 dla instalatora). ```powershell git clone https://github.com/willowdev-code/xpp-lens.git C:\Dev\xpp-lens cd C:\Dev\xpp-lens -.\pack.ps1 # tworzy dist\xpp-lens\ i dist\xpp-lens-X.Y.Z.zip +.\pack.ps1 # dist\xpp-lens\, dist\xpp-lens-X.Y.Z.zip (i instalator, gdy jest Inno Setup) powershell -ExecutionPolicy Bypass -File .\dist\xpp-lens\install.ps1 -Languages en-US,pl ``` @@ -39,8 +47,8 @@ układu katalogów), zapisuje konfigurację, rejestruje serwer w Claude Desktop otwarty: po instalacji zacznij nową sesję (albo uruchom ponownie Claude Desktop), żeby wczytał serwer. Aktualizacja instalacji: `xpplens update` pokazuje, czy jest nowsze wydanie; `xpplens update --install` pobiera je -(z kontrolą SHA-256) i uruchamia jego instalator — ustawienia i indeks zostają. To samo daje uruchomienie `install.ps1` -z nowszej paczki. Nie trzeba zamykać Claude'a: `xpplens.exe` używany przez otwartą sesję dostaje nazwę `*.old` +(z kontrolą SHA-256) i uruchamia jego instalator — od 1.3.0 kreator, po cichu, do bieżącej instalacji; ustawienia +i indeks zostają. To samo daje uruchomienie instalatora albo `install.ps1` z nowszej paczki. Nie trzeba zamykać Claude'a: `xpplens.exe` używany przez otwartą sesję dostaje nazwę `*.old` (usuwaną później), więc ta sesja działa dalej na starej wersji, a nowe sesje startują już na nowej. Na końcu instalator proponuje ponowne uruchomienie Claude Desktop (`xpplens update --install --restart-claude` albo `install.ps1 -RestartClaude` — bez pytania); w Claude Code to samo dla jednej sesji daje `/mcp` → reconnect xpp-lens. diff --git a/install.ps1 b/install.ps1 index 2eb3b9d..f4f9c61 100644 --- a/install.ps1 +++ b/install.ps1 @@ -50,38 +50,6 @@ $ErrorActionPreference = 'Stop' function Say($m) { Write-Host " $m" } function Head($m) { Write-Host "`n$m" -ForegroundColor Cyan } -# How to start Claude Desktop again, from the path of its running process; $null when it cannot be worked out. -function Get-ClaudeStart([string]$appPath) { - if ($appPath -match '\\WindowsApps\\(?[^\\]+)\\') { - # Store / MSIX app, started through its application id. The folder ("Claude_2.1.0.0_x64__pzs8sxrjxfjjc") gives - # the package family ("Claude_pzs8sxrjxfjjc"); after Claude updated itself the running folder is an older - # version than the installed package, so the family is matched, never the path. - $folder = $Matches['folder'] - if ($folder -notmatch '^(?[^_]+)_.*__(?[^_]+)$') { return $null } - $family = "$($Matches['name'])_$($Matches['publisher'])" - $aumid = $null - try { - $aumid = (Get-StartApps -ErrorAction Stop | Where-Object { $_.AppID -like "$family!*" } | Select-Object -First 1).AppID - } - catch { } - if (-not $aumid) { - try { - $pkg = Get-AppxPackage -ErrorAction Stop | Where-Object { $_.PackageFamilyName -eq $family } | Select-Object -First 1 - if ($pkg) { $aumid = "$family!" + @((Get-AppxPackageManifest $pkg).Package.Applications.Application)[0].Id } - } - catch { } - } - if ($aumid) { return "shell:AppsFolder\$aumid" } - return $null - } - # Per-user install: the launcher above the versioned "app-x.y.z" folders starts the current version. - if ($appPath -match '^(?.+)\\app-[^\\]+\\[^\\]+$' -and (Test-Path (Join-Path $Matches['root'] 'claude.exe'))) { - return (Join-Path $Matches['root'] 'claude.exe') - } - if ($appPath -and (Test-Path $appPath)) { return $appPath } - return $null -} - $src = $PSScriptRoot if (-not (Test-Path (Join-Path $src 'bin\xpplens.exe'))) { throw "$src\bin\xpplens.exe not found - run install.ps1 from an unpacked xpp-lens package." @@ -146,7 +114,7 @@ else { Copy-Item $f.FullName $dst -Force } Get-ChildItem (Join-Path $src 'bin') -Directory | ForEach-Object { Copy-Item $_.FullName $binDst -Recurse -Force } - foreach ($f in 'install.ps1', 'uninstall.ps1', 'README.md', 'README.pl.md', 'CHANGELOG.md', 'LICENSE') { + foreach ($f in 'install.ps1', 'uninstall.ps1', 'restart-claude.ps1', 'README.md', 'README.pl.md', 'CHANGELOG.md', 'LICENSE') { if (Test-Path (Join-Path $src $f)) { Copy-Item (Join-Path $src $f) $InstallDir -Force } } } @@ -233,22 +201,8 @@ if ($desktop.Count -gt 0) { $restart = $answer -match '^\s*(y|yes|t|tak)\s*$' } if ($restart) { - # Worked out before Claude is stopped: when it cannot be started again, it is not stopped either. - $start = Get-ClaudeStart $desktop[0].Path - if (-not $start) { - Say "Claude Desktop could not be located for a restart - it was left running; restart it yourself to use $version." - } - else { - $desktop | Stop-Process -Force -ErrorAction SilentlyContinue - Start-Sleep -Seconds 2 - try { - Start-Process $start -ErrorAction Stop - Say "Claude Desktop restarted." - } - catch { - Say "Claude Desktop was closed but did not start again ($($_.Exception.Message)) - start it from the Start menu." - } - } + # Shared with the setup program: works out how to start Claude before stopping it. + & (Join-Path $src 'restart-claude.ps1') -Version $version } else { Say "Claude Desktop: open sessions keep the old version until it is restarted; new sessions use $version." diff --git a/pack.ps1 b/pack.ps1 index d372dcd..c885259 100644 --- a/pack.ps1 +++ b/pack.ps1 @@ -1,22 +1,29 @@ <# .SYNOPSIS - Builds the xpp-lens distribution package (ZIP) for installing on another machine. + Builds the xpp-lens distribution: the ZIP package and the setup program. .DESCRIPTION By default publishes a self-contained build (single EXE): nothing needs to be installed on the target machine. With -FrameworkDependent the package is small but requires the .NET 9 Runtime on the target machine. + Outputs in OutDir: + xpp-lens-X.Y.Z.zip program and scripts (install.ps1; updates of versions before 1.3.0) + xpp-lens-setup-X.Y.Z.exe setup program with a wizard (Inno Setup 6; skipped when ISCC.exe is not found, + unless -Installer) + .EXAMPLE .\pack.ps1 .EXAMPLE - .\pack.ps1 -FrameworkDependent + .\pack.ps1 -Installer # fail when Inno Setup is missing (CI) #> [CmdletBinding()] param( [string] $OutDir = (Join-Path $PSScriptRoot 'dist'), [switch] $FrameworkDependent, - [switch] $NoZip + [switch] $NoZip, + # Fail when Inno Setup (ISCC.exe) is not found instead of skipping the setup program. + [switch] $Installer ) $ErrorActionPreference = 'Stop' @@ -25,6 +32,8 @@ $OutDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPat $proj = Join-Path $PSScriptRoot 'src\XppLens\XppLens.csproj' $stage = Join-Path $OutDir 'xpp-lens' $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' +$version = ([xml](Get-Content $proj -Raw)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 +if (-not $version) { $version = Get-Date -Format 'yyyyMMdd' } if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } New-Item -ItemType Directory -Force -Path $stage | Out-Null @@ -47,7 +56,7 @@ Write-Host "publishing ($(if ($FrameworkDependent) { 'requires .NET 9' } else { if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed" } Remove-Item (Join-Path $stage 'bin\*.pdb') -Force -ErrorAction SilentlyContinue -foreach ($f in 'install.ps1', 'uninstall.ps1', 'README.md', 'README.pl.md', 'CHANGELOG.md', 'LICENSE') { +foreach ($f in 'install.ps1', 'uninstall.ps1', 'restart-claude.ps1', 'README.md', 'README.pl.md', 'CHANGELOG.md', 'LICENSE') { Copy-Item (Join-Path $PSScriptRoot $f) $stage -Force } @@ -55,8 +64,6 @@ $size = [math]::Round((Get-ChildItem $stage -Recurse -File | Measure-Object Leng Write-Host "package: $stage ($size MB)" -ForegroundColor Cyan if (-not $NoZip) { - $version = ([xml](Get-Content $proj -Raw)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 - if (-not $version) { $version = Get-Date -Format 'yyyyMMdd' } $zip = Join-Path $OutDir "xpp-lens-$version.zip" if (Test-Path $zip) { Remove-Item $zip -Force } # Entries with '/' as the ZIP format requires (Compress-Archive in Windows PowerShell 5.1 writes '\'). @@ -75,4 +82,18 @@ if (-not $NoZip) { Write-Host "zip: $zip ($([math]::Round((Get-Item $zip).Length / 1MB, 1)) MB)" -ForegroundColor Cyan } -Write-Host "`nOn the target machine: unzip and run install.ps1" -ForegroundColor Green +# Setup program with a wizard (Inno Setup 6). +$iscc = @((Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source, + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe", + "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe") | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1 +if ($iscc) { + Write-Host 'building the setup program (Inno Setup)...' -ForegroundColor Cyan + & $iscc /Q "/DVersion=$version" "/DSource=$stage" "/DOutDir=$OutDir" (Join-Path $PSScriptRoot 'packaging\inno\xpp-lens.iss') + if ($LASTEXITCODE -ne 0) { throw 'Inno Setup failed' } + $setup = Join-Path $OutDir "xpp-lens-setup-$version.exe" + Write-Host "setup: $setup ($([math]::Round((Get-Item $setup).Length / 1MB, 1)) MB)" -ForegroundColor Cyan +} +elseif ($Installer) { throw 'Inno Setup 6 (ISCC.exe) not found' } +else { Write-Host 'setup program skipped: Inno Setup 6 (ISCC.exe) not found' -ForegroundColor Yellow } + +Write-Host "`nOn the target machine: run xpp-lens-setup-$version.exe (or unzip and run install.ps1)" -ForegroundColor Green diff --git a/packaging/inno/xpp-lens.iss b/packaging/inno/xpp-lens.iss new file mode 100644 index 0000000..41e7b24 --- /dev/null +++ b/packaging/inno/xpp-lens.iss @@ -0,0 +1,380 @@ +; xpp-lens setup program: per user, no administrator rights, a wizard with windows (Polish or English). +; Built by pack.ps1: ISCC /DVersion=X.Y.Z /DSource= /DOutDir= xpp-lens.iss +; The wizard calls xpplens.exe for the real work (detect, config, register, build), so every step stays scriptable. +; Silent update (xpplens update --install): xpp-lens-setup-X.Y.Z.exe /SILENT /SUPPRESSMSGBOXES /NORESTART /DIR="..." +; [/RESTARTCLAUDE=1] restarts Claude Desktop at the end. + +#ifndef Version + #define Version "0.0.0" +#endif +#ifndef Source + #define Source "..\..\dist\xpp-lens" +#endif +#ifndef OutDir + #define OutDir "..\..\dist" +#endif +; The file version is numbers only: 1.3.0 for a test release 1.3.0-beta.2. +#define FileVersion Copy(Version, 1, Pos("-", Version + "-") - 1) + +[Setup] +AppId={{B3C9E031-73CD-4356-AC30-B8EC91C3C528} +AppName=xpp-lens +AppVersion={#Version} +AppVerName=xpp-lens {#Version} +AppPublisher=WillowDev +AppPublisherURL=https://github.com/willowdev-code/xpp-lens +AppSupportURL=https://github.com/willowdev-code/xpp-lens/issues +; An installation made by install.ps1 (C:\Tools\xpp-lens) is taken over in place: settings and index stay. +DefaultDirName={code:DefaultInstallDir} +DisableDirPage=yes +DisableProgramGroupPage=yes +DefaultGroupName=xpp-lens +DisableReadyPage=yes +; The program folder goes on the user's PATH, so "xpplens" works in any new terminal. +ChangesEnvironment=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir={#OutDir} +OutputBaseFilename=xpp-lens-setup-{#Version} +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +UninstallDisplayName=xpp-lens +UninstallDisplayIcon={app}\bin\xpplens.exe +LicenseFile={#Source}\LICENSE +ShowLanguageDialog=yes +CloseApplications=no +VersionInfoVersion={#FileVersion} + +[Languages] +Name: "en"; MessagesFile: "compiler:Default.isl" +Name: "pl"; MessagesFile: "compiler:Languages\Polish.isl" + +[Files] +Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion + +[Icons] +; Start menu: commands open in a console that stays open (xpplens commands work there too). +Name: "{group}\{cm:IconStatus}"; Filename: "{cmd}"; Parameters: "/k """"{app}\bin\xpplens.exe"" status"""; WorkingDir: "{app}"; IconFilename: "{app}\bin\xpplens.exe" +Name: "{group}\{cm:IconBuild}"; Filename: "{cmd}"; Parameters: "/k """"{app}\bin\xpplens.exe"" build"""; WorkingDir: "{app}"; IconFilename: "{app}\bin\xpplens.exe" +Name: "{group}\{cm:IconUpdate}"; Filename: "{cmd}"; Parameters: "/k """"{app}\bin\xpplens.exe"" update"""; WorkingDir: "{app}"; IconFilename: "{app}\bin\xpplens.exe" +Name: "{group}\{cm:UninstallProgram,xpp-lens}"; Filename: "{uninstallexe}"; IconFilename: "{app}\bin\xpplens.exe" + +[Registry] +Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}\bin"; Check: NeedsAddPath(ExpandConstant('{app}\bin')) + +[UninstallDelete] +Type: files; Name: "{app}\bin\*.old" + +[UninstallRun] +Filename: "{app}\bin\xpplens.exe"; Parameters: "unregister"; Flags: runhidden waituntilterminated; RunOnceId: "unregister" + +[Run] +Filename: "{cmd}"; Parameters: "/k """"{app}\bin\xpplens.exe"" build"""; WorkingDir: "{app}"; Description: "{cm:BuildNow}"; Flags: postinstall nowait skipifsilent; Check: IsFreshInstall +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{app}\restart-claude.ps1"" -Version {#Version}"; Description: "{cm:RestartClaude}"; Flags: postinstall nowait skipifsilent unchecked runhidden; Check: IsUpdate +Filename: "{cm:ReadmeUrl}"; Description: "{cm:OpenReadme}"; Flags: postinstall shellexec skipifsilent unchecked + +[CustomMessages] +en.PkgTitle=PackagesLocalDirectory +en.PkgSub=Where the X++ code of this machine is +en.PkgText=xpp-lens indexes the folder with the AOT packages (read only - nothing is ever written there). +en.PkgLabel=Folder: +en.PkgDetecting=Looking for PackagesLocalDirectory... +en.PkgFound=Found:%n%1 +en.PkgNone=No PackagesLocalDirectory was found automatically - choose the folder (usually ...\AosService\PackagesLocalDirectory). +en.PkgInvalid=This folder does not look like a PackagesLocalDirectory (no packages with a Descriptor folder):%n%n%1 +en.LangTitle=Label languages +en.LangSub=Label texts shown next to label ids +en.LangText=Languages of the label texts to index, separated by commas (for example en-US, pl, de). More languages make the index larger. +en.LangLabel=Languages: +en.StdTitle=Microsoft standard code +en.StdSub=Objects, signatures and calls of the standard models +en.StdText=Recommended: Claude then also finds callers and extensions inside Microsoft code. The first indexing takes 20-90 minutes (in the background), later only what changed. +en.StdOption=Index the Microsoft standard models (recommended) +en.ConfigFailed=Saving the settings did not work:%n%n%1 +en.Registered=xpp-lens is ready. Open Claude and ask, for example: "find the method SalesTable.validateWrite". +en.BuildNow=Build the index now (a console window shows the progress) +en.RestartClaude=Restart Claude Desktop so all its sessions use the new version (interrupts work in progress there) +en.OpenReadme=Read how to use xpp-lens +en.ReadmeUrl=https://github.com/willowdev-code/xpp-lens#readme +en.IconStatus=xpp-lens - status +en.IconBuild=xpp-lens - update the index +en.IconUpdate=xpp-lens - check for updates +en.RemoveData=Also remove the index (about 1 GB), the settings and the usage log of xpp-lens?%n%nChoose No to keep them for a later installation. +pl.PkgTitle=PackagesLocalDirectory +pl.PkgSub=Gdzie jest kod X++ tej maszyny +pl.PkgText=xpp-lens indeksuje folder z pakietami AOT (tylko odczyt - nic tam nie jest zapisywane). +pl.PkgLabel=Folder: +pl.PkgDetecting=Szukam PackagesLocalDirectory... +pl.PkgFound=Znaleziono:%n%1 +pl.PkgNone=Nie znaleziono PackagesLocalDirectory automatycznie - wskaż folder (zwykle ...\AosService\PackagesLocalDirectory). +pl.PkgInvalid=Ten folder nie wygląda na PackagesLocalDirectory (brak pakietów z folderem Descriptor):%n%n%1 +pl.LangTitle=Języki etykiet +pl.LangSub=Teksty etykiet pokazywane obok ich identyfikatorów +pl.LangText=Języki tekstów etykiet do indeksu, po przecinku (np. en-US, pl, de). Więcej języków to większy indeks. +pl.LangLabel=Języki: +pl.StdTitle=Kod standardowy Microsoftu +pl.StdSub=Obiekty, sygnatury i wywołania modeli standardowych +pl.StdText=Zalecane: Claude znajdzie wtedy także wywołania i rozszerzenia w kodzie Microsoftu. Pierwsze indeksowanie trwa 20-90 minut (w tle), później tylko zmiany. +pl.StdOption=Indeksuj modele standardowe Microsoftu (zalecane) +pl.ConfigFailed=Nie udało się zapisać ustawień:%n%n%1 +pl.Registered=xpp-lens jest gotowy. Otwórz Claude'a i zapytaj np.: "znajdź metodę SalesTable.validateWrite". +pl.BuildNow=Zbuduj indeks teraz (okno konsoli pokaże postęp) +pl.RestartClaude=Uruchom ponownie Claude Desktop, żeby wszystkie jego sesje używały nowej wersji (przerywa trwającą tam pracę) +pl.OpenReadme=Przeczytaj, jak używać xpp-lens +pl.ReadmeUrl=https://github.com/willowdev-code/xpp-lens/blob/main/README.pl.md +pl.IconStatus=xpp-lens - status +pl.IconBuild=xpp-lens - aktualizacja indeksu +pl.IconUpdate=xpp-lens - sprawdź aktualizacje +pl.RemoveData=Usunąć też indeks (ok. 1 GB), ustawienia i dziennik użycia xpp-lens?%n%nWybierz Nie, aby zachować je na potrzeby ponownej instalacji. + +[Code] +var + PkgPage: TInputDirWizardPage; + PkgInfo: TNewStaticText; + LangPage: TInputQueryWizardPage; + StdPage: TInputOptionWizardPage; + Detected: Boolean; + WasUpdate: Boolean; + RemoveUserData: Boolean; + +{ Where to install: an installation made by install.ps1 is taken over in place, else the per-user programs folder. } +function DefaultInstallDir(Param: String): String; +begin + if FileExists('C:\Tools\xpp-lens\bin\xpplens.exe') then + Result := 'C:\Tools\xpp-lens' + else + Result := ExpandConstant('{localappdata}\Programs\xpp-lens'); +end; + +{ An installation with settings (an update) skips the questions and keeps its settings and index. } +function HasSettings: Boolean; +begin + Result := FileExists(AddBackslash(WizardDirValue) + 'xpplens.json'); +end; + +function IsFreshInstall: Boolean; +begin + Result := not WasUpdate; +end; + +function IsUpdate: Boolean; +begin + Result := WasUpdate; +end; + +{ Whether a folder is missing from the user's PATH (compared without case and trailing backslash). } +function NeedsAddPath(Dir: String): Boolean; +var + Path: String; +begin + if not RegQueryStringValue(HKCU, 'Environment', 'Path', Path) then + begin + Result := True; + Exit; + end; + Result := Pos(';' + Lowercase(RemoveBackslashUnlessRoot(Dir)) + ';', ';' + Lowercase(Path) + ';') = 0; +end; + +procedure RemoveFromPath(Dir: String); +var + Path, Item, Kept: String; + P: Integer; +begin + if not RegQueryStringValue(HKCU, 'Environment', 'Path', Path) then Exit; + Kept := ''; + Path := Path + ';'; + while Path <> '' do + begin + P := Pos(';', Path); + Item := Copy(Path, 1, P - 1); + Delete(Path, 1, P); + if (Item <> '') and (Lowercase(RemoveBackslashUnlessRoot(Item)) <> Lowercase(RemoveBackslashUnlessRoot(Dir))) then + begin + if Kept <> '' then Kept := Kept + ';'; + Kept := Kept + Item; + end; + end; + RegWriteExpandStringValue(HKCU, 'Environment', 'Path', Kept); +end; + +{ Runs xpplens.exe with its output in a file; returns the exit code and the output. } +function RunXpp(const Exe, Params: String; var Output: String): Integer; +var + OutFile: String; + Text: AnsiString; + Code: Integer; +begin + OutFile := ExpandConstant('{tmp}\xpplens-out.txt'); + DeleteFile(OutFile); + if not Exec(ExpandConstant('{cmd}'), '/C ""' + Exe + '" ' + Params + ' > "' + OutFile + '" 2>&1"', '', SW_HIDE, ewWaitUntilTerminated, Code) then + Code := -1; + Output := ''; + if LoadStringFromFile(OutFile, Text) then Output := Trim(String(Text)); + Result := Code; +end; + +{ Number of packages (subfolders with a Descriptor folder) - a PackagesLocalDirectory has many. } +function CountPackages(Dir: String): Integer; +var + F: TFindRec; +begin + Result := 0; + if not FindFirst(AddBackslash(Dir) + '*', F) then Exit; + try + repeat + if (F.Attributes and FILE_ATTRIBUTE_DIRECTORY <> 0) and (F.Name <> '.') and (F.Name <> '..') and + DirExists(AddBackslash(Dir) + F.Name + '\Descriptor') then + Result := Result + 1; + until (Result >= 3) or not FindNext(F); + finally + FindClose(F); + end; +end; + +procedure InitializeWizard; +begin + PkgPage := CreateInputDirPage(wpLicense, CustomMessage('PkgTitle'), CustomMessage('PkgSub'), CustomMessage('PkgText'), False, ''); + PkgPage.Add(CustomMessage('PkgLabel')); + PkgInfo := TNewStaticText.Create(PkgPage); + PkgInfo.Parent := PkgPage.Surface; + PkgInfo.AutoSize := False; + PkgInfo.WordWrap := True; + PkgInfo.Width := PkgPage.SurfaceWidth; + PkgInfo.Height := ScaleY(80); + PkgInfo.Top := PkgPage.Edits[0].Top + PkgPage.Edits[0].Height + ScaleY(16); + PkgInfo.Caption := CustomMessage('PkgDetecting'); + + LangPage := CreateInputQueryPage(PkgPage.ID, CustomMessage('LangTitle'), CustomMessage('LangSub'), CustomMessage('LangText')); + LangPage.Add(CustomMessage('LangLabel'), False); + if ActiveLanguage = 'pl' then LangPage.Values[0] := 'en-US, pl' else LangPage.Values[0] := 'en-US'; + + StdPage := CreateInputOptionPage(LangPage.ID, CustomMessage('StdTitle'), CustomMessage('StdSub'), CustomMessage('StdText'), False, False); + StdPage.Add(CustomMessage('StdOption')); + StdPage.Values[0] := True; +end; + +{ Fills the folder page from "xpplens detect" (lines: path TAB packages TAB source). } +procedure DetectPackages; +var + Exe, OutFile, Line, Found: String; + Lines: TArrayOfString; + I, P: Integer; + Code: Integer; +begin + Detected := True; + ExtractTemporaryFile('xpplens.exe'); + Exe := ExpandConstant('{tmp}\xpplens.exe'); + OutFile := ExpandConstant('{tmp}\xpplens-detect.txt'); + DeleteFile(OutFile); + Exec(ExpandConstant('{cmd}'), '/C ""' + Exe + '" detect > "' + OutFile + '" 2>&1"', '', SW_HIDE, ewWaitUntilTerminated, Code); + if not LoadStringsFromFile(OutFile, Lines) then SetArrayLength(Lines, 0); + Found := ''; + for I := 0 to GetArrayLength(Lines) - 1 do + begin + Line := Lines[I]; + P := Pos(#9, Line); + if P > 1 then + begin + if PkgPage.Values[0] = '' then PkgPage.Values[0] := Copy(Line, 1, P - 1); + StringChangeEx(Line, #9, ' ', True); + Found := Found + Line + #13#10; + end; + end; + if Found = '' then + PkgInfo.Caption := CustomMessage('PkgNone') + else + PkgInfo.Caption := FmtMessage(CustomMessage('PkgFound'), [Found]); +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + Result := HasSettings and ((PageID = PkgPage.ID) or (PageID = LangPage.ID) or (PageID = StdPage.ID)); +end; + +procedure CurPageChanged(CurPageID: Integer); +begin + if (CurPageID = PkgPage.ID) and not Detected then + begin + WizardForm.NextButton.Enabled := False; + WizardForm.Repaint; + try + DetectPackages; + finally + WizardForm.NextButton.Enabled := True; + end; + end; + if CurPageID = wpFinished then + WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + #13#10#13#10 + CustomMessage('Registered'); +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + if (CurPageID = PkgPage.ID) and (CountPackages(PkgPage.Values[0]) < 3) then + begin + MsgBox(FmtMessage(CustomMessage('PkgInvalid'), [PkgPage.Values[0]]), mbError, MB_OK); + Result := False; + end; +end; + +{ A running xpplens.exe (an open Claude session) cannot be overwritten but can be renamed; xpplens removes *.old + files when it starts. Whether this is an update is decided here, before the files are copied. } +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + Exe: String; +begin + Result := ''; + WasUpdate := FileExists(ExpandConstant('{app}\xpplens.json')); + Exe := ExpandConstant('{app}\bin\xpplens.exe'); + if FileExists(Exe) then + RenameFile(Exe, Exe + '.' + GetDateTimeString('yyyymmddhhnnss', #0, #0) + '.old'); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + Exe, Params, Langs, Output: String; + Code: Integer; +begin + if CurStep <> ssPostInstall then Exit; + Exe := ExpandConstant('{app}\bin\xpplens.exe'); + if not WasUpdate then + begin + Langs := Trim(LangPage.Values[0]); + StringChangeEx(Langs, ' ', '', True); + StringChangeEx(Langs, ';', ',', True); + StringChangeEx(Langs, '"', '', True); + if Langs = '' then Langs := 'en-US'; + Params := 'config --packages-dir "' + RemoveBackslashUnlessRoot(PkgPage.Values[0]) + '" --languages "' + Langs + '"'; + if not StdPage.Values[0] then Params := Params + ' --index-standard false'; + Code := RunXpp(Exe, Params, Output); + if (Code <> 0) and not WizardSilent then MsgBox(FmtMessage(CustomMessage('ConfigFailed'), [Output]), mbError, MB_OK); + end; + { Registration in Claude Desktop and Claude Code (an update re-registers the same path). } + RunXpp(Exe, 'register', Output); + { Silent update with /RESTARTCLAUDE=1 (xpplens update --install --restart-claude). } + if ExpandConstant('{param:RESTARTCLAUDE|0}') = '1' then + Exec('powershell.exe', '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + ExpandConstant('{app}\restart-claude.ps1') + '" -Version {#Version}', + '', SW_HIDE, ewNoWait, Code); +end; + +{ Before the files go: ask whether to remove the index and settings (default: keep them). After: take the program + folder off the PATH and delete the data when asked to. } +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + RemoveUserData := not UninstallSilent and + (MsgBox(CustomMessage('RemoveData'), mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES) + else if CurUninstallStep = usPostUninstall then + begin + RemoveFromPath(ExpandConstant('{app}\bin')); + if RemoveUserData then + begin + DelTree(ExpandConstant('{localappdata}\xpp-lens'), True, True, True); + DeleteFile(ExpandConstant('{app}\xpplens.json')); + RemoveDir(ExpandConstant('{app}\bin')); + RemoveDir(ExpandConstant('{app}')); + end; + end; +end; diff --git a/release-notes.ps1 b/release-notes.ps1 index 0ce5507..3bf2f15 100644 --- a/release-notes.ps1 +++ b/release-notes.ps1 @@ -51,15 +51,19 @@ $($testNote)X++ (Dynamics 365 F&O) code index served to AI assistants over MCP. ## Install -Download ``xpp-lens-$Version.zip``, unzip it and run in a regular (non-admin) PowerShell: +Run ``xpp-lens-setup-$Version.exe`` - a wizard for the current user, no administrator rights: it finds +PackagesLocalDirectory, asks for the label languages, registers xpp-lens in Claude and builds the index. The program +is not signed yet: when Windows shows "Windows protected your PC", choose "More info" > "Run anyway". + +Without the wizard: unzip ``xpp-lens-$Version.zip`` and run in a regular (non-admin) PowerShell: ``````powershell powershell -ExecutionPolicy Bypass -File .\xpp-lens\install.ps1 -Languages en-US,pl `````` Self-contained build - no .NET required on the target machine. Already installed? Run -``xpplens update --install`` (available from 1.2.2), or ``install.ps1`` from this package - your settings and -index are kept, Claude may stay open (from 1.2.5). +``xpplens update --install`` - your settings and index are kept, Claude may stay open. From 1.3.0 it updates through +the setup program; the setup program also takes over an installation made by install.ps1 (C:\Tools\xpp-lens). ## Changes diff --git a/release.ps1 b/release.ps1 index fad85e1..d8097fb 100644 --- a/release.ps1 +++ b/release.ps1 @@ -241,6 +241,17 @@ foreach ($e in 'xpp-lens/install.ps1', 'xpp-lens/bin/xpplens.exe', 'xpp-lens/CHA } Ok "${asset}: SHA-256 matches, layout ok, $(if ($Pre) { 'test release' } else { 'regular release' })" +# The setup program: present, with a digest that matches what is downloaded (xpplens update --install uses it). +$setupName = "xpp-lens-setup-$fullVersion.exe" +$setupDigest = (@($release.assets) | Where-Object { $_.name -eq $setupName } | Select-Object -First 1).digest +if (-not $setupDigest) { Fail "release $tag has no $setupName" } +New-Item -ItemType Directory $tmp | Out-Null +& $gh release download $tag --pattern $setupName --dir $tmp; Native 'gh release download (setup)' +$setupSha = (Get-FileHash (Join-Path $tmp $setupName) -Algorithm SHA256).Hash.ToLowerInvariant() +Remove-Item $tmp -Recurse -Force +if ("sha256:$setupSha" -ne $setupDigest) { Fail "downloaded $setupName does not match the published digest ($setupDigest)" } +Ok "${setupName}: SHA-256 matches" + if (-not $Pre) { # main = dev + the merge commit: dev moves forward to it, so the next pull request starts clean. git merge --quiet --ff-only origin/main diff --git a/restart-claude.ps1 b/restart-claude.ps1 new file mode 100644 index 0000000..c8a520e --- /dev/null +++ b/restart-claude.ps1 @@ -0,0 +1,67 @@ +<# +.SYNOPSIS + Restarts Claude Desktop so all its sessions start the newly installed xpp-lens. + +.DESCRIPTION + Used by install.ps1 and by the setup program (xpp-lens-setup-X.Y.Z.exe) after an update. Does nothing when Claude + Desktop is not running. Works out how to start Claude again *before* stopping it; when that is not possible, + Claude is left running. Only the Desktop app (Store / MSIX or per-user install) is touched - never Claude Code + processes in terminals. +#> +[CmdletBinding()] +param([string] $Version) + +$ErrorActionPreference = 'Stop' +function Say($m) { Write-Host " $m" } + +# How to start Claude Desktop again, from the path of its running process; $null when it cannot be worked out. +function Get-ClaudeStart([string]$appPath) { + if ($appPath -match '\\WindowsApps\\(?[^\\]+)\\') { + # Store / MSIX app, started through its application id. The folder ("Claude_2.1.0.0_x64__pzs8sxrjxfjjc") gives + # the package family ("Claude_pzs8sxrjxfjjc"); after Claude updated itself the running folder is an older + # version than the installed package, so the family is matched, never the path. + $folder = $Matches['folder'] + if ($folder -notmatch '^(?[^_]+)_.*__(?[^_]+)$') { return $null } + $family = "$($Matches['name'])_$($Matches['publisher'])" + $aumid = $null + try { + $aumid = (Get-StartApps -ErrorAction Stop | Where-Object { $_.AppID -like "$family!*" } | Select-Object -First 1).AppID + } + catch { } + if (-not $aumid) { + try { + $pkg = Get-AppxPackage -ErrorAction Stop | Where-Object { $_.PackageFamilyName -eq $family } | Select-Object -First 1 + if ($pkg) { $aumid = "$family!" + @((Get-AppxPackageManifest $pkg).Package.Applications.Application)[0].Id } + } + catch { } + } + if ($aumid) { return "shell:AppsFolder\$aumid" } + return $null + } + # Per-user install: the launcher above the versioned "app-x.y.z" folders starts the current version. + if ($appPath -match '^(?.+)\\app-[^\\]+\\[^\\]+$' -and (Test-Path (Join-Path $Matches['root'] 'claude.exe'))) { + return (Join-Path $Matches['root'] 'claude.exe') + } + if ($appPath -and (Test-Path $appPath)) { return $appPath } + return $null +} + +$desktop = @(Get-Process -Name claude -ErrorAction SilentlyContinue | + Where-Object { $_.Path -match '\\WindowsApps\\Claude_|\\AnthropicClaude\\' }) +if ($desktop.Count -eq 0) { Say "Claude Desktop is not running - nothing to restart."; exit 0 } + +$start = Get-ClaudeStart $desktop[0].Path +if (-not $start) { + Say "Claude Desktop could not be located for a restart - it was left running; restart it yourself$(if ($Version) { " to use $Version" })." + exit 0 +} +$desktop | Stop-Process -Force -ErrorAction SilentlyContinue +Start-Sleep -Seconds 2 +try { + Start-Process $start -ErrorAction Stop + Say "Claude Desktop restarted." +} +catch { + Say "Claude Desktop was closed but did not start again ($($_.Exception.Message)) - start it from the Start menu." + exit 1 +} diff --git a/src/XppLens/Updater.cs b/src/XppLens/Updater.cs index d4770dd..b76e356 100644 --- a/src/XppLens/Updater.cs +++ b/src/XppLens/Updater.cs @@ -51,8 +51,11 @@ public static class Updater { public const string Repository = "willowdev-code/xpp-lens"; + /// The setup program of the release (xpp-lens-setup-X.Y.Z.exe, from 1.3.0), when it has one. public sealed record ReleaseInfo(ReleaseVersion Version, string Tag, string PageUrl, string? AssetName, string? AssetUrl, string? Sha256, - string Notes, bool Prerelease = false); + string Notes, bool Prerelease = false, ReleaseAsset? Setup = null); + + public sealed record ReleaseAsset(string Name, string Url, string? Sha256); /// /// The running version, with a test-release suffix ("1.3.0-beta.1") — from the informational version, which keeps @@ -96,15 +99,20 @@ public sealed record ReleaseInfo(ReleaseVersion Version, string Tag, string Page { var tag = n?["tag_name"]?.GetValue(); if (ParseVersion(tag) is not { } version) return null; - var asset = (n!["assets"] as JsonArray)?.OfType() - .FirstOrDefault(a => a["name"]?.GetValue() is { } name - && name.StartsWith("xpp-lens-", StringComparison.OrdinalIgnoreCase) - && name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); - var digest = asset?["digest"]?.GetValue(); + var assets = (n!["assets"] as JsonArray)?.OfType().ToList() ?? []; + JsonObject? Find(string prefix, string extension) => assets.FirstOrDefault(a => a["name"]?.GetValue() is { } name + && name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && name.EndsWith(extension, StringComparison.OrdinalIgnoreCase)); + static string? Sha(JsonObject? a) => a?["digest"]?.GetValue() is { } d && d.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) + ? d[7..].ToLowerInvariant() + : null; + var asset = Find("xpp-lens-", ".zip"); + var setup = Find("xpp-lens-setup-", ".exe"); return new ReleaseInfo(version, tag!, n["html_url"]?.GetValue() ?? $"https://github.com/{Repository}/releases", - asset?["name"]?.GetValue(), asset?["browser_download_url"]?.GetValue(), - digest != null && digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? digest[7..].ToLowerInvariant() : null, - n["body"]?.GetValue() ?? "", n["prerelease"]?.GetValue() ?? version.IsPrerelease); + asset?["name"]?.GetValue(), asset?["browser_download_url"]?.GetValue(), Sha(asset), + n["body"]?.GetValue() ?? "", n["prerelease"]?.GetValue() ?? version.IsPrerelease, + setup?["browser_download_url"]?.GetValue() is { } setupUrl + ? new ReleaseAsset(setup["name"]!.GetValue(), setupUrl, Sha(setup)) + : null); } static string StateDir => @@ -237,6 +245,11 @@ public static async Task Run(bool install, bool restartClaude = false, bool return 0; } + // From 1.3.0 a release has a setup program: it is run silently into this installation. Older releases (and a + // release without its digest) go the ZIP + install.ps1 way. + if (latest.Setup is { Sha256: not null } setup) + return await RunSetup(http, latest, setup, restartClaude); + if (latest.AssetUrl == null || latest.AssetName == null) { Console.Error.WriteLine($"release {latest.Tag} has no xpp-lens-*.zip package - download it from {latest.PageUrl}"); @@ -289,4 +302,37 @@ public static async Task Run(bool install, bool restartClaude = false, bool : "At the end it offers to restart Claude Desktop; otherwise running sessions keep the old version until restarted."); return 0; } + + /// The arguments of a silent update by the setup program into . + internal static string SetupArguments(string installDir, bool restartClaude) => + $"/SILENT /SUPPRESSMSGBOXES /NORESTART /DIR=\"{installDir.TrimEnd('\\')}\"" + (restartClaude ? " /RESTARTCLAUDE=1" : ""); + + static async Task RunSetup(HttpClient http, ReleaseInfo latest, ReleaseAsset setup, bool restartClaude) + { + var work = Path.Combine(Path.GetTempPath(), $"xpp-lens-update-{latest.Version}"); + if (Directory.Exists(work)) Directory.Delete(work, recursive: true); + Directory.CreateDirectory(work); + var exe = Path.Combine(work, setup.Name); + Console.WriteLine($"downloading {setup.Name}…"); + await using (var src = await http.GetStreamAsync(setup.Url)) + await using (var dst = File.Create(exe)) + await src.CopyToAsync(dst); + + var actual = Sha256Of(exe); + if (!actual.Equals(setup.Sha256, StringComparison.OrdinalIgnoreCase)) + { + Console.Error.WriteLine($"SHA-256 mismatch for {setup.Name}: expected {setup.Sha256}, got {actual} - not installing"); + return 1; + } + Console.WriteLine($"SHA-256 verified: {actual}"); + + // The setup program renames this running xpplens.exe and copies the new one; settings and index are kept. + var target = InstallDir(); + Process.Start(new ProcessStartInfo { FileName = exe, Arguments = SetupArguments(target, restartClaude), UseShellExecute = true }); + Console.WriteLine($"the setup program updates {target} in its own window (progress only, no questions)."); + Console.WriteLine(restartClaude + ? "Claude Desktop is restarted when it is done." + : "Running Claude sessions keep the old version until restarted; new sessions use the new one."); + return 0; + } } diff --git a/tests/XppLens.Tests/MigrationTests.cs b/tests/XppLens.Tests/MigrationTests.cs index 4a4ad3b..553841a 100644 --- a/tests/XppLens.Tests/MigrationTests.cs +++ b/tests/XppLens.Tests/MigrationTests.cs @@ -197,6 +197,21 @@ public void Release_json_gives_package_and_digest() Assert.Contains("- Faster status", r.Notes); } + [Fact] + public void Release_with_a_setup_program_offers_it() + { + var r = Updater.ParseRelease(""" + { "tag_name": "v1.3.0", "assets": [ + { "name": "xpp-lens-1.3.0.zip", "browser_download_url": "https://example.invalid/z.zip", "digest": "sha256:AA" }, + { "name": "xpp-lens-setup-1.3.0.exe", "browser_download_url": "https://example.invalid/s.exe", "digest": "sha256:BB" } ] } + """)!; + Assert.Equal("xpp-lens-1.3.0.zip", r.AssetName); + Assert.Equal(new Updater.ReleaseAsset("xpp-lens-setup-1.3.0.exe", "https://example.invalid/s.exe", "bb"), r.Setup); + Assert.Null(Updater.ParseRelease(Release)!.Setup); + Assert.Equal("/SILENT /SUPPRESSMSGBOXES /NORESTART /DIR=\"C:\\Tools\\xpp-lens\"", Updater.SetupArguments(@"C:\Tools\xpp-lens\", false)); + Assert.EndsWith(" /RESTARTCLAUDE=1", Updater.SetupArguments(@"C:\Tools\xpp-lens", true)); + } + [Fact] public void Release_without_package_has_no_asset() { From 04d18ab9298df196bc3507ae4c7a81e3c8b77d2c Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:00:28 +0200 Subject: [PATCH 3/4] xpp-lens 1.3.0-beta.2 (test release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Added - Setup program `xpp-lens-setup-X.Y.Z.exe` (Inno Setup): a wizard in Polish or English for the current user, no administrator rights. It finds PackagesLocalDirectory (`xpplens detect`, or choose the folder), asks for the label languages and whether to index the Microsoft standard, registers xpp-lens in Claude Desktop and Claude Code and offers to build the index. It puts `xpplens` on the user's PATH, adds Start menu entries (status, update the index, check for updates, uninstall) and an entry in Windows' installed apps; uninstalling asks whether to remove the index. An installation made by `install.ps1` (`C:\Tools\xpp-lens`) is taken over in place with its settings and index. - `xpplens update --install` runs the setup program of the release silently into the current installation (SHA-256 checked, `--restart-claude` passed on); releases without one still go through the ZIP and `install.ps1`. - `restart-claude.ps1`: the Claude Desktop restart of the installers, shared by `install.ps1` and the setup program. - Test releases: a version can first be published as `X.Y.Z-beta.N`, marked as a pre-release on GitHub. `xpplens update` never offers it; `xpplens update --install --pre` installs the newest release including test releases, so a new version can be tried on one machine before everyone gets it. The regular `X.Y.Z` later replaces a test release through the usual `xpplens update --install`. - `xpplens version`, `status` and the MCP server report the full version of a test release (`1.3.0-beta.1`); versions compare with test releases before their final version (`1.3.0-beta.2` < `1.3.0`). ### Development - Work happens on the branch `dev`; `main` always holds the last regular release. `release.ps1 -Version X.Y.Z -Pre` publishes a test release from `dev`; `release.ps1 -Version X.Y.Z` opens the pull request `dev` → `main`, waits for its CI, merges it (merge commit), tags the merge and publishes the release, then moves `dev` to `main`. CI also runs for pushes to `dev`. The release notes of a test release come from the section of its final version. --- src/XppLens/XppLens.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/XppLens/XppLens.csproj b/src/XppLens/XppLens.csproj index 175aa9b..1819bf9 100644 --- a/src/XppLens/XppLens.csproj +++ b/src/XppLens/XppLens.csproj @@ -20,7 +20,7 @@ xpp-lens — X++ code index for MCP X++ (Dynamics 365 F&O) code index served to AI assistants over MCP Copyright © 2026 WillowDev - 1.3.0-beta.1 + 1.3.0-beta.2 From bd2ce5e9686cf54f511b90c722414cbafa41b7c5 Mon Sep 17 00:00:00 2001 From: WillowDev <216433317+willowdev-code@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:03:19 +0100 Subject: [PATCH 4/4] xpp-lens 1.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Added - Setup program `xpp-lens-setup-X.Y.Z.exe` (Inno Setup): a wizard in Polish or English for the current user, no administrator rights. It finds PackagesLocalDirectory (`xpplens detect`, or choose the folder), asks for the label languages and whether to index the Microsoft standard, registers xpp-lens in Claude Desktop and Claude Code and offers to build the index. It puts `xpplens` on the user's PATH, adds Start menu entries (status, update the index, check for updates, uninstall) and an entry in Windows' installed apps; uninstalling asks whether to remove the index. An installation made by `install.ps1` (`C:\Tools\xpp-lens`) is taken over in place with its settings and index. - `xpplens update --install` runs the setup program of the release silently into the current installation (SHA-256 checked, `--restart-claude` passed on); releases without one still go through the ZIP and `install.ps1`. - `restart-claude.ps1`: the Claude Desktop restart of the installers, shared by `install.ps1` and the setup program. - Test releases: a version can first be published as `X.Y.Z-beta.N`, marked as a pre-release on GitHub. `xpplens update` never offers it; `xpplens update --install --pre` installs the newest release including test releases, so a new version can be tried on one machine before everyone gets it. The regular `X.Y.Z` later replaces a test release through the usual `xpplens update --install`. - `xpplens version`, `status` and the MCP server report the full version of a test release (`1.3.0-beta.1`); versions compare with test releases before their final version (`1.3.0-beta.2` < `1.3.0`). ### Development - Work happens on the branch `dev`; `main` always holds the last regular release. `release.ps1 -Version X.Y.Z -Pre` publishes a test release from `dev`; `release.ps1 -Version X.Y.Z` opens the pull request `dev` → `main`, waits for its CI, merges it (merge commit), tags the merge and publishes the release, then moves `dev` to `main`. CI also runs for pushes to `dev`. The release notes of a test release come from the section of its final version. --- CHANGELOG.md | 2 +- src/XppLens/XppLens.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c1b10..8b29ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to xpp-lens (called xpp-graft up to 1.2.0). Versions follow `` in `src/XppLens/XppLens.csproj`; release tags are `vX.Y.Z`. -## 1.3.0 — unreleased +## 1.3.0 — 2026-10-26 ### Added - Setup program `xpp-lens-setup-X.Y.Z.exe` (Inno Setup): a wizard in Polish or English for the current user, no diff --git a/src/XppLens/XppLens.csproj b/src/XppLens/XppLens.csproj index 1819bf9..3b4bef2 100644 --- a/src/XppLens/XppLens.csproj +++ b/src/XppLens/XppLens.csproj @@ -20,7 +20,7 @@ xpp-lens — X++ code index for MCP X++ (Dynamics 365 F&O) code index served to AI assistants over MCP Copyright © 2026 WillowDev - 1.3.0-beta.2 + 1.3.0