diff --git a/CHANGELOG.md b/CHANGELOG.md index cf27df491..348a804c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,6 +173,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In tag-based CFML, calls written in tags are now linked: in ``, ``/``, ``, `` and `#…#` expressions. Before, only `` and `` code was read, so callers and impact found almost nothing in tag-based components. Functions wrapped in tags like `` or `` are now indexed too, and a `` is indexed as an interface that `implements` links to. Re-index CFML projects after upgrading. Thanks @HarryMuc for the report. (#2091) - On Windows, a project opened with a different drive-letter or folder-name case, like `d:\work\app` and `D:\Work\App`, now joins the CodeGraph background server that is already running for it. Before, each spelling started a server of its own that exited at once, so the session quietly served the graph by itself with no shared file watching or auto-sync, and `codegraph list` showed the project twice. Thanks @greatjackzhou. (#2278) - An agent session that lost its connection to CodeGraph's shared background server, or couldn't reach it at startup, no longer stops that server from running for the rest of the session. It reconnects on its own once the server can start, so other sessions on the same project get auto-sync back instead of running read-only. Thanks @ijbranch for the report. (#2277) +- On Windows, `codegraph upgrade` no longer breaks the install while an agent session is using CodeGraph. It used to stop partway and leave CodeGraph unable to start; it now swaps the new files in with CodeGraph running and puts the previous version back if anything fails, and re-running the PowerShell installer works the same way. If an earlier upgrade already broke your install, re-run `irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex` to repair it. Thanks @tippmar-nr for the report. (#2185) ## [1.6.1] - 2026-09-29 diff --git a/__tests__/upgrade.test.ts b/__tests__/upgrade.test.ts index e1b05e4d4..ad46a17ad 100644 --- a/__tests__/upgrade.test.ts +++ b/__tests__/upgrade.test.ts @@ -16,6 +16,8 @@ import { verifyResolvedVersion, defaultWirePromptHook, buildWindowsUpgradeScript, + WINDOWS_SWAP_FUNCTION, + WINDOWS_UPGRADE_DAMAGED, NPM_PACKAGE, type InstallMethod, type UpgradeDeps, @@ -211,17 +213,124 @@ describe('version helpers', () => { expect(a).toContain('codegraph index -f'); }); - it('buildWindowsUpgradeScript targets the right asset per arch and renames-not-deletes the exe', () => { + it('buildWindowsUpgradeScript targets the right asset per arch', () => { const arm = buildWindowsUpgradeScript('C:\\cg\\current', 'v1.2.3', 'arm64'); expect(arm).toContain('releases/download/v1.2.3/codegraph-win32-arm64.zip'); expect(arm).toContain("$dest='C:\\cg\\current'"); - expect(arm).toContain('Rename-Item'); // never Remove-Item on the locked exe - expect(arm).not.toMatch(/Remove-Item[^;]*\$dest'?\s*;/); // doesn't delete current\ + expect(arm).toContain("Join-Path $stage 'codegraph-win32-arm64'"); const x64 = buildWindowsUpgradeScript('C:\\cg\\current', 'v1.2.3', 'x64'); expect(x64).toContain('codegraph-win32-x64.zip'); }); }); +// --------------------------------------------------------------------------- +// Windows file swap (#2185) — a running CodeGraph process (an agent session's +// MCP server) keeps node.exe and the native kernel locked: they can be renamed +// but not overwritten or deleted. The upgrade used to rename only node.exe and +// then Copy-Item over the rest, so the locked kernel failed the copy halfway +// and left the install with no node.exe. PowerShell can't run on the macOS +// test host, so these pin the script's shape; the behavior is validated on a +// real Windows machine. +// --------------------------------------------------------------------------- + +describe('windows bundle swap script (#2185)', () => { + const INSTALL_PS1 = path.join(__dirname, '..', 'install.ps1'); + const installPs1 = () => fs.readFileSync(INSTALL_PS1, 'utf-8').replace(/\r\n/g, '\n'); + const script = () => buildWindowsUpgradeScript('C:\\Users\\me\\AppData\\Local\\codegraph\\current', 'v1.6.2', 'x64'); + /** The swap function's own statements, comments dropped. */ + const swapCode = () => WINDOWS_SWAP_FUNCTION.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n'); + + it('install.ps1 carries the same swap function, verbatim', () => { + const m = /# >>> Install-CodeGraphFiles[^\n]*\n([\s\S]*?)# <<< Install-CodeGraphFiles/.exec(installPs1()); + expect(m, 'install.ps1 lost its Install-CodeGraphFiles markers').not.toBeNull(); + expect(m![1]).toBe(WINDOWS_SWAP_FUNCTION); + }); + + it('never copies over or deletes current\\ in place', () => { + for (const text of [script(), installPs1()]) { + expect(text).not.toMatch(/Copy-Item/); + expect(text).not.toMatch(/Remove-Item[^\n]*\$dest\b/); + expect(text).not.toMatch(/Expand-Archive[^\n]*\$dest\b/); + } + }); + + it('unpacks next to current\\ (same volume), not into %TEMP%, then swaps', () => { + const s = script(); + expect(s).toContain(`$stage=Join-Path (Split-Path -Parent $dest) ('.staging-'`); + expect(s).toMatch(/Expand-Archive -Path \$zip -DestinationPath \$stage/); + expect(s.indexOf('Expand-Archive')).toBeLessThan(s.indexOf('Install-CodeGraphFiles $(')); + // install.ps1 stages next to current\ too. + expect(installPs1()).toContain(`$stage = Join-Path $installDir ('.staging-'`); + expect(installPs1()).toMatch(/Install-CodeGraphFiles \$\(.*\) \$dest$/m); + }); + + it('renames every replaced file aside before moving the staged file in', () => { + const code = swapCode(); + // Replaced files and files the new version drops are both renamed aside… + expect(code.match(/Move-Logged \$at "\$at\.old-\$token"/g)).toHaveLength(2); + // …and the aside-rename of a target comes before the staged file moves in. + const aside = code.indexOf('if ([IO.File]::Exists($at)) { Move-Logged $at "$at.old-$token" }'); + const moveIn = code.indexOf('Move-Logged ($stageDir + $rel) $at'); + expect(aside).toBeGreaterThan(0); + expect(moveIn).toBeGreaterThan(aside); + // No in-place overwrite or delete of a live file during the swap. + expect(code).not.toMatch(/\[IO\.File\]::(Copy|Replace)\(/); + expect(code).not.toMatch(/Move-Item|Rename-Item/); + }); + + it('rolls back on failure and on interruption, and says whether the install still works', () => { + const code = swapCode(); + expect(code).toMatch(/catch \{[\s\S]*\$lost = Undo-Logged[\s\S]*\} finally \{\s*if \(-not \$done\) \{ \[void\]\(Undo-Logged\) \}/); + // Undo walks the log backwards, moving each file back to where it was. + expect(code).toContain('for ($n = $undo.Count - 1; $n -ge 0; $n--)'); + expect(code).toContain('[IO.File]::Move($u[1], $u[0])'); + expect(code).toContain('Nothing was changed: the existing install still works.'); + expect(code).toContain("$e.Data['codegraphDamaged'] = [bool]$lost"); + // The upgrade script maps that flag to the exit code runUpgrade reads. + expect(script()).toContain(`$code=if($_.Exception.Data['codegraphDamaged']){${WINDOWS_UPGRADE_DAMAGED}}else{1}`); + expect(script().trimEnd().endsWith('exit $code')).toBe(true); + }); + + it('refuses a download that is not a bundle before touching current\\', () => { + const code = swapCode(); + expect(code).toContain("foreach ($need in 'node.exe', 'bin\\codegraph.cmd')"); + expect(code.indexOf("foreach ($need in")).toBeLessThan(code.indexOf('try {')); + }); + + it('cleans up renamed-aside leftovers, including ones from earlier upgrades', () => { + const m = /\$asideName = '([^']+)'/.exec(WINDOWS_SWAP_FUNCTION); + expect(m).not.toBeNull(); + const aside = new RegExp(m![1]!, 'i'); // PowerShell -match is case-insensitive + // The pre-fix upgrade left node.exe.old-<32-hex guid>; this one uses 8 hex. + expect(aside.test('node.exe.old-0123456789abcdef0123456789ABCDEF')).toBe(true); + expect(aside.test('codegraph-kernel.node.old-deadbeef')).toBe(true); + for (const shipped of ['node.exe', 'codegraph-kernel.node', 'old-deadbeef.js', 'x.old-1234567', 'a.old-deadbeef.js']) { + expect(aside.test(shipped), shipped).toBe(false); + } + // No file a bundle actually ships looks like a leftover. + const walk = (dir: string): string[] => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [e.name])); + const distDir = path.join(__dirname, '..', 'dist'); + if (fs.existsSync(distDir)) expect(walk(distDir).filter((n) => aside.test(n))).toEqual([]); + // Deleted only after a successful swap, and a still-locked one is skipped. + const code = swapCode(); + expect(code.indexOf('[IO.File]::Delete($f.FullName)')).toBeGreaterThan(code.indexOf('$done = $true')); + expect(code).toContain('try { [IO.File]::Delete($f.FullName) } catch {}'); + }); + + it('quotes the install path for PowerShell', () => { + const s = buildWindowsUpgradeScript("C:\\Users\\o'brien\\codegraph\\current", 'v1.6.2', 'x64'); + expect(s).toContain("$dest='C:\\Users\\o''brien\\codegraph\\current'"); + }); + + it('fits a Windows command line even for a long install path', () => { + const root = `C:\\${'very-long-directory-name\\'.repeat(8)}codegraph\\current`; + const encoded = Buffer.from(buildWindowsUpgradeScript(root, 'v10.20.30', 'arm64'), 'utf16le').toString('base64'); + // CreateProcess caps the whole command line at 32,767 characters. + expect(encoded.length).toBeLessThan(24_000); + }); +}); + // --------------------------------------------------------------------------- // runUpgrade orchestration — mocked side-effects // --------------------------------------------------------------------------- @@ -335,11 +444,10 @@ describe('runUpgrade', () => { expect(calls.runs).toHaveLength(1); expect(calls.runs[0].cmd).toBe('powershell.exe'); const decoded = decodeEncodedCommand(calls.runs[0].args); - // Downloads the right asset, renames the locked exe aside, copies over current\. + // Downloads the right asset and swaps it in with the shared rename-aside function. expect(decoded).toContain('releases/download/v0.9.9/codegraph-win32-'); - expect(decoded).toContain('Rename-Item'); - expect(decoded).toContain('node.exe.old-'); - expect(decoded).toContain('Copy-Item'); + expect(decoded).toContain(WINDOWS_SWAP_FUNCTION); + expect(decoded).toMatch(/^\s*Install-CodeGraphFiles .* \$dest$/m); }); it('windows bundle: a non-zero installer exit is a failure', async () => { @@ -353,7 +461,37 @@ describe('runUpgrade', () => { ); const code = await runUpgrade({}, deps); expect(code).toBe(1); - expect(calls.errors.join('\n')).toMatch(/exited with code/i); + // Exit 1 is the script's "failed, install unchanged" code (#2185). + expect(calls.errors.join('\n')).toMatch(/did not complete; your existing install was left as it was/i); + }); + + it('windows bundle: an incomplete rollback points at the reinstall command', async () => { + const { deps, calls } = makeDeps( + { + method: { kind: 'bundle', os: 'windows', bundleRoot: 'C:/x/codegraph/current', installDir: 'C:/x/codegraph' }, + currentVersion: '0.9.8', + platform: 'win32', + }, + WINDOWS_UPGRADE_DAMAGED + ); + const code = await runUpgrade({}, deps); + expect(code).toBe(1); + expect(calls.errors.join('\n')).toMatch(/could not be put back/i); + expect(calls.logs.join('\n')).toContain('install.ps1 | iex'); + expect(calls.runs).toHaveLength(1); // no post-upgrade refresh/probe after a failure + }); + + it('windows bundle: any other exit code is reported as-is', async () => { + const { deps, calls } = makeDeps( + { + method: { kind: 'bundle', os: 'windows', bundleRoot: 'C:/x/codegraph/current', installDir: 'C:/x/codegraph' }, + currentVersion: '0.9.8', + platform: 'win32', + }, + -1 + ); + expect(await runUpgrade({}, deps)).toBe(1); + expect(calls.errors.join('\n')).toMatch(/exited with code -1/i); }); it('npm global: shells out to npm install -g @pkg@latest', async () => { diff --git a/install.ps1 b/install.ps1 index 7c0b523ca..a079205b4 100644 --- a/install.ps1 +++ b/install.ps1 @@ -5,7 +5,8 @@ # # irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex # -# Upgrade with `codegraph upgrade` (or just re-run this). To uninstall: remove +# Upgrade with `codegraph upgrade` (or just re-run this -- safe even while agent +# sessions are running CodeGraph). To uninstall: remove # $env:LOCALAPPDATA\codegraph and drop its \current\bin entry from your user PATH. # # Environment: @@ -13,6 +14,81 @@ # CODEGRAPH_INSTALL_DIR install location (default: %LOCALAPPDATA%\codegraph) $ErrorActionPreference = 'Stop' + +# >>> Install-CodeGraphFiles -- keep identical to WINDOWS_SWAP_FUNCTION in src/upgrade/index.ts +function Install-CodeGraphFiles([string]$Stage, [string]$Dest) { + # Move an unpacked bundle into $Dest. Windows can't overwrite or delete a + # running node.exe or a loaded .node addon, but it can rename one, so every + # file being replaced (or dropped by the new version) is first renamed aside + # to .old-. Any failure puts every file back, so the install is + # never left half-replaced or without its node.exe. + $ErrorActionPreference = 'Stop' + $stageDir = (Resolve-Path -LiteralPath $Stage).ProviderPath.TrimEnd('\') + foreach ($need in 'node.exe', 'bin\codegraph.cmd') { + if (-not (Test-Path -LiteralPath (Join-Path $stageDir $need))) { throw "The CodeGraph download is incomplete (no $need); nothing was changed." } + } + $token = [guid]::NewGuid().ToString('N').Substring(0, 8) + $asideName = '\.old-[0-9a-f]{8,32}$' + $files = @{}; $dirs = @{} + foreach ($i in @(Get-ChildItem -LiteralPath $stageDir -Recurse -Force)) { + $rel = $i.FullName.Substring($stageDir.Length) + if ($i.PSIsContainer) { $dirs[$rel] = $true } else { $files[$rel] = $true } + } + $undo = New-Object System.Collections.ArrayList + function Move-Logged([string]$From, [string]$To) { [IO.File]::Move($From, $To); [void]$undo.Add(@($From, $To)) } + function Undo-Logged { + $lost = 0 + for ($n = $undo.Count - 1; $n -ge 0; $n--) { + $u = $undo[$n] + try { if ($u.Count -eq 2) { [IO.File]::Move($u[1], $u[0]) } else { [IO.Directory]::Delete($u[0]) } } catch { if ($u.Count -eq 2) { $lost++ } } + } + $undo.Clear() + $lost + } + $done = $false; $at = $Dest + try { + if (-not (Test-Path -LiteralPath $Dest)) { [void][IO.Directory]::CreateDirectory($Dest); [void]$undo.Add(@($Dest)) } + $destDir = (Resolve-Path -LiteralPath $Dest).ProviderPath.TrimEnd('\') + foreach ($f in @(Get-ChildItem -LiteralPath $destDir -Recurse -Force -File)) { + if (-not $files.ContainsKey($f.FullName.Substring($destDir.Length)) -and $f.Name -notmatch $asideName) { + $at = $f.FullName; Move-Logged $at "$at.old-$token" + } + } + foreach ($rel in @($dirs.Keys | Sort-Object Length)) { + $at = $destDir + $rel + if (-not [IO.Directory]::Exists($at)) { [void][IO.Directory]::CreateDirectory($at); [void]$undo.Add(@($at)) } + } + foreach ($rel in @($files.Keys)) { + $at = $destDir + $rel + if ([IO.File]::Exists($at)) { Move-Logged $at "$at.old-$token" } + Move-Logged ($stageDir + $rel) $at + } + $done = $true + } catch { + $x = $_.Exception; while ($x.InnerException) { $x = $x.InnerException } + $lost = Undo-Logged + $msg = "Could not replace $at ($($x.Message))." + if ($lost) { + $msg += " $lost file(s) could not be put back, so the install may not start. Close your agent sessions and any running codegraph commands, then reinstall: irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex" + } else { + $msg += " Nothing was changed: the existing install still works. If another program has CodeGraph's files open, close your agent sessions (they run the CodeGraph MCP server) and any running codegraph commands, then try again." + } + $e = New-Object System.Exception($msg); $e.Data['codegraphDamaged'] = [bool]$lost; throw $e + } finally { + # Interrupted (Ctrl+C) without reaching catch: still put everything back. + if (-not $done) { [void](Undo-Logged) } + } + # Delete what this run and earlier ones renamed aside. A file a running + # process still holds can't be deleted yet; the next install retries it. + foreach ($f in @(Get-ChildItem -LiteralPath $destDir -Recurse -Force -File -ErrorAction SilentlyContinue)) { + if ($f.Name -match $asideName) { try { [IO.File]::Delete($f.FullName) } catch {} } + } + foreach ($d in @(Get-ChildItem -LiteralPath $destDir -Recurse -Force -Directory -ErrorAction SilentlyContinue | Sort-Object { $_.FullName.Length } -Descending)) { + if (-not $dirs.ContainsKey($d.FullName.Substring($destDir.Length))) { try { [IO.Directory]::Delete($d.FullName) } catch {} } + } +} +# <<< Install-CodeGraphFiles + $repo = 'colbymchenry/codegraph' $installDir = if ($env:CODEGRAPH_INSTALL_DIR) { $env:CODEGRAPH_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'codegraph' } @@ -27,25 +103,28 @@ if (-not $version) { } if (-not $version) { throw "codegraph: could not resolve latest version; set CODEGRAPH_VERSION." } -# 3. Download + extract the bundle into a stable 'current' dir (overwritten on upgrade). +# 3. Download the bundle and unpack it next to the stable 'current' dir (same +# volume, so moving it in is renames, not copies), then move it into 'current'. +# 'current' is never deleted wholesale: a CodeGraph process that is still +# running (an agent session's MCP server) holds node.exe and the native kernel, +# which can't be deleted -- Install-CodeGraphFiles renames them aside instead. $url = "https://github.com/$repo/releases/download/$version/codegraph-$target.zip" Write-Host "Installing CodeGraph $version ($target)..." $tmp = Join-Path $env:TEMP ("cg-" + [guid]::NewGuid().ToString()) New-Item -ItemType Directory -Force -Path $tmp | Out-Null $zip = Join-Path $tmp 'cg.zip' -Invoke-WebRequest -Uri $url -OutFile $zip - $dest = Join-Path $installDir 'current' -if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } -New-Item -ItemType Directory -Force -Path $dest | Out-Null -Expand-Archive -Path $zip -DestinationPath $dest -Force -# Archives contain a top-level codegraph-\ dir; flatten it. -$inner = Join-Path $dest "codegraph-$target" -if (Test-Path $inner) { - Get-ChildItem -Force $inner | Move-Item -Destination $dest -Force - Remove-Item -Recurse -Force $inner +$stage = Join-Path $installDir ('.staging-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +try { + Invoke-WebRequest -Uri $url -OutFile $zip + New-Item -ItemType Directory -Force -Path $stage | Out-Null + Expand-Archive -Path $zip -DestinationPath $stage -Force + # Archives contain a top-level codegraph-\ dir. + $inner = Join-Path $stage "codegraph-$target" + Install-CodeGraphFiles $(if (Test-Path $inner) { $inner } else { $stage }) $dest +} finally { + Remove-Item -LiteralPath $stage, $tmp -Recurse -Force -ErrorAction SilentlyContinue } -Remove-Item -Recurse -Force $tmp # 4. Put the launcher dir on the user's PATH. $binDir = Join-Path $dest 'bin' diff --git a/src/upgrade/index.ts b/src/upgrade/index.ts index 76354378a..0e088e998 100644 --- a/src/upgrade/index.ts +++ b/src/upgrade/index.ts @@ -17,11 +17,12 @@ * vendored `node` binary and a `bin/codegraph` launcher next to its `lib/`, so * we can recognize it from the running file's path without a marker file. * - * Windows wrinkle: a running `node.exe` is locked and can't be deleted, so the - * bundle's `current\` dir can't be overwritten in place by the process doing - * the upgrade. We therefore spawn a DETACHED helper that waits for this - * process to exit (releasing the lock), then runs `install.ps1`. This is the - * conventional Windows self-update dance (rustup/nvm-windows do the same). + * Windows wrinkle: a running `node.exe` and a loaded `.node` addon are locked — + * they can't be overwritten or deleted, by this process or by the MCP servers + * of open agent sessions. They CAN be renamed, so the Windows upgrade unpacks + * the new bundle next to `current\` and swaps it in file by file, renaming each + * replaced file aside and rolling every step back on failure (see + * `WINDOWS_SWAP_FUNCTION`, shared verbatim with `install.ps1`). */ import * as fs from 'fs'; @@ -34,6 +35,7 @@ export const REPO = 'colbymchenry/codegraph'; export const NPM_PACKAGE = '@colbymchenry/codegraph'; const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main`; export const INSTALL_SH_URL = `${RAW_BASE}/install.sh`; +export const INSTALL_PS1_URL = `${RAW_BASE}/install.ps1`; // --------------------------------------------------------------------------- // Install-method detection (pure — fully unit-testable via injected probes) @@ -578,36 +580,140 @@ function upgradeUnixBundle( return 0; } +/** + * The PowerShell function that moves an unpacked Windows bundle into the + * install's `current\` dir. Shared VERBATIM with `install.ps1` (a test pins the + * two copies equal), so a first install, a re-run of the installer, and + * `codegraph upgrade` all replace files the same way. + * + * Why file-by-file renames (#2185): every open agent session runs a CodeGraph + * MCP server from `current\`, which keeps `node.exe` and the native kernel + * (`lib\kernel\codegraph-kernel.node`) locked. Windows refuses to overwrite or + * delete a running exe or a loaded DLL but does let it be renamed. The old + * upgrade renamed only `node.exe` and then `Copy-Item`ed over the rest, so the + * locked kernel failed the copy halfway — leaving no `node.exe` and a mix of + * versions, which no `codegraph` command could repair. Now each file being + * replaced (or dropped by the new version) is renamed aside to + * `.old-` before the staged file is moved in; any failure undoes + * every step, and the renamed-aside files are deleted on the next successful + * run once nothing holds them. + */ +export const WINDOWS_SWAP_FUNCTION = String.raw`function Install-CodeGraphFiles([string]$Stage, [string]$Dest) { + # Move an unpacked bundle into $Dest. Windows can't overwrite or delete a + # running node.exe or a loaded .node addon, but it can rename one, so every + # file being replaced (or dropped by the new version) is first renamed aside + # to .old-. Any failure puts every file back, so the install is + # never left half-replaced or without its node.exe. + $ErrorActionPreference = 'Stop' + $stageDir = (Resolve-Path -LiteralPath $Stage).ProviderPath.TrimEnd('\') + foreach ($need in 'node.exe', 'bin\codegraph.cmd') { + if (-not (Test-Path -LiteralPath (Join-Path $stageDir $need))) { throw "The CodeGraph download is incomplete (no $need); nothing was changed." } + } + $token = [guid]::NewGuid().ToString('N').Substring(0, 8) + $asideName = '\.old-[0-9a-f]{8,32}$' + $files = @{}; $dirs = @{} + foreach ($i in @(Get-ChildItem -LiteralPath $stageDir -Recurse -Force)) { + $rel = $i.FullName.Substring($stageDir.Length) + if ($i.PSIsContainer) { $dirs[$rel] = $true } else { $files[$rel] = $true } + } + $undo = New-Object System.Collections.ArrayList + function Move-Logged([string]$From, [string]$To) { [IO.File]::Move($From, $To); [void]$undo.Add(@($From, $To)) } + function Undo-Logged { + $lost = 0 + for ($n = $undo.Count - 1; $n -ge 0; $n--) { + $u = $undo[$n] + try { if ($u.Count -eq 2) { [IO.File]::Move($u[1], $u[0]) } else { [IO.Directory]::Delete($u[0]) } } catch { if ($u.Count -eq 2) { $lost++ } } + } + $undo.Clear() + $lost + } + $done = $false; $at = $Dest + try { + if (-not (Test-Path -LiteralPath $Dest)) { [void][IO.Directory]::CreateDirectory($Dest); [void]$undo.Add(@($Dest)) } + $destDir = (Resolve-Path -LiteralPath $Dest).ProviderPath.TrimEnd('\') + foreach ($f in @(Get-ChildItem -LiteralPath $destDir -Recurse -Force -File)) { + if (-not $files.ContainsKey($f.FullName.Substring($destDir.Length)) -and $f.Name -notmatch $asideName) { + $at = $f.FullName; Move-Logged $at "$at.old-$token" + } + } + foreach ($rel in @($dirs.Keys | Sort-Object Length)) { + $at = $destDir + $rel + if (-not [IO.Directory]::Exists($at)) { [void][IO.Directory]::CreateDirectory($at); [void]$undo.Add(@($at)) } + } + foreach ($rel in @($files.Keys)) { + $at = $destDir + $rel + if ([IO.File]::Exists($at)) { Move-Logged $at "$at.old-$token" } + Move-Logged ($stageDir + $rel) $at + } + $done = $true + } catch { + $x = $_.Exception; while ($x.InnerException) { $x = $x.InnerException } + $lost = Undo-Logged + $msg = "Could not replace $at ($($x.Message))." + if ($lost) { + $msg += " $lost file(s) could not be put back, so the install may not start. Close your agent sessions and any running codegraph commands, then reinstall: irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex" + } else { + $msg += " Nothing was changed: the existing install still works. If another program has CodeGraph's files open, close your agent sessions (they run the CodeGraph MCP server) and any running codegraph commands, then try again." + } + $e = New-Object System.Exception($msg); $e.Data['codegraphDamaged'] = [bool]$lost; throw $e + } finally { + # Interrupted (Ctrl+C) without reaching catch: still put everything back. + if (-not $done) { [void](Undo-Logged) } + } + # Delete what this run and earlier ones renamed aside. A file a running + # process still holds can't be deleted yet; the next install retries it. + foreach ($f in @(Get-ChildItem -LiteralPath $destDir -Recurse -Force -File -ErrorAction SilentlyContinue)) { + if ($f.Name -match $asideName) { try { [IO.File]::Delete($f.FullName) } catch {} } + } + foreach ($d in @(Get-ChildItem -LiteralPath $destDir -Recurse -Force -Directory -ErrorAction SilentlyContinue | Sort-Object { $_.FullName.Length } -Descending)) { + if (-not $dirs.ContainsKey($d.FullName.Substring($destDir.Length))) { try { [IO.Directory]::Delete($d.FullName) } catch {} } + } +} +`; + +/** Single-quote a value for PowerShell (a `'` inside is doubled). */ +function psQuote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +/** Exit code of the upgrade script when files could not all be put back. */ +export const WINDOWS_UPGRADE_DAMAGED = 2; + /** Build the in-place Windows upgrade script (exported for unit-testing). */ export function buildWindowsUpgradeScript(bundleRoot: string, version: string, arch: string): string { const target = `win32-${arch}`; const url = `https://github.com/${REPO}/releases/download/${version}/codegraph-${target}.zip`; - // Windows can't DELETE a running exe but CAN rename it, so we upgrade IN - // PLACE: download → rename the locked node.exe aside → extract the new bundle - // over current\. Synchronous, no detached helper (which dies under SSH/job - // objects and has worse UX). The running process keeps its renamed node.exe - // mapped; the NEXT `codegraph` invocation uses the new one. We can't reuse - // install.ps1 here — it `Remove-Item`s current\, which fails on the locked exe. + // Synchronous, no detached helper (which dies under SSH/job objects and has + // worse UX). The bundle is unpacked into a sibling of current\ — the same + // volume, so the swap is renames, never a half-finished copy — and nothing + // in current\ changes until it is fully unpacked. The running process keeps + // its renamed node.exe mapped; the NEXT `codegraph` invocation uses the new + // one. Exit codes: 0 installed, 1 failed with the install unchanged, + // WINDOWS_UPGRADE_DAMAGED when the rollback could not restore every file. return [ `$ErrorActionPreference='Stop'`, - `$dest='${bundleRoot}'`, - `$url='${url}'`, - `Write-Host "Downloading $url"`, + WINDOWS_SWAP_FUNCTION, + `$dest=${psQuote(bundleRoot)}`, + `$url=${psQuote(url)}`, `$tmp=Join-Path $env:TEMP ('cg-up-'+[guid]::NewGuid().ToString('N'))`, - `New-Item -ItemType Directory -Force -Path $tmp | Out-Null`, - `$zip=Join-Path $tmp 'cg.zip'`, - `Invoke-WebRequest -Uri $url -OutFile $zip`, - `$stage=Join-Path $tmp 'stage'`, - `Expand-Archive -Path $zip -DestinationPath $stage -Force`, - `$inner=Join-Path $stage 'codegraph-${target}'`, - `$src=if(Test-Path $inner){$inner}else{$stage}`, - `$node=Join-Path $dest 'node.exe'`, - `if(Test-Path $node){Rename-Item -Path $node -NewName ('node.exe.old-'+[guid]::NewGuid().ToString('N')) -Force}`, - `Copy-Item -Path (Join-Path $src '*') -Destination $dest -Recurse -Force`, - `Get-ChildItem -Path $dest -Filter 'node.exe.old-*' -ErrorAction SilentlyContinue | ForEach-Object { try { Remove-Item $_.FullName -Force -ErrorAction Stop } catch {} }`, - `Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue`, - `Write-Host "Installed CodeGraph ${version} to $dest"`, - ].join(';'); + `$stage=Join-Path (Split-Path -Parent $dest) ('.staging-'+[guid]::NewGuid().ToString('N').Substring(0,8))`, + `$code=0`, + `try {`, + ` Write-Host "Downloading $url"`, + ` New-Item -ItemType Directory -Force -Path $tmp | Out-Null`, + ` $zip=Join-Path $tmp 'cg.zip'`, + ` Invoke-WebRequest -Uri $url -OutFile $zip`, + ` Expand-Archive -Path $zip -DestinationPath $stage -Force`, + ` $inner=Join-Path $stage 'codegraph-${target}'`, + ` Install-CodeGraphFiles $(if(Test-Path $inner){$inner}else{$stage}) $dest`, + ` Write-Host "Installed CodeGraph ${version} to $dest"`, + `} catch {`, + ` [Console]::Error.WriteLine($_.Exception.Message)`, + ` $code=if($_.Exception.Data['codegraphDamaged']){${WINDOWS_UPGRADE_DAMAGED}}else{1}`, + `}`, + `Remove-Item -LiteralPath $stage,$tmp -Recurse -Force -ErrorAction SilentlyContinue`, + `exit $code`, + ].join('\n'); } function upgradeWindowsBundle( @@ -624,7 +730,15 @@ function upgradeWindowsBundle( deps.log(c.dim(`Downloading and installing ${latest}…`)); const code = deps.run('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded]); if (code !== 0) { - deps.error(`Installer exited with code ${code}.`); + // The script has already printed what went wrong and what to do. + if (code === WINDOWS_UPGRADE_DAMAGED) { + deps.error('The upgrade failed and some files could not be put back.'); + deps.log(c.dim(`Close your agent sessions and any running codegraph commands, then reinstall: irm ${INSTALL_PS1_URL} | iex`)); + } else if (code === 1) { + deps.error(`The upgrade to ${latest} did not complete; your existing install was left as it was.`); + } else { + deps.error(`Installer exited with code ${code}.`); + } return 1; } deps.log('');