diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2f7161..1e95f41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,9 @@ jobs: - target: aarch64-unknown-linux-gnu os: ubuntu-22.04 artifact: graphiq-aarch64-unknown-linux-gnu + - target: x86_64-pc-windows-msvc + os: windows-2022 + artifact: graphiq-x86_64-pc-windows-msvc runs-on: ${{ matrix.os }} @@ -61,23 +64,44 @@ jobs: - name: Build release run: cargo build --release --target ${{ matrix.target }} --features gpu - - name: Package + - name: Package (Unix) + if: runner.os != 'Windows' run: | + set -euo pipefail mkdir -p dist for bin in graphiq graphiq-mcp graphiq-bench; do - if [ -f "target/${{ matrix.target }}/release/$bin" ]; then - cp "target/${{ matrix.target }}/release/$bin" dist/ - fi + test -f "target/${{ matrix.target }}/release/$bin" + cp "target/${{ matrix.target }}/release/$bin" dist/ done cd dist && tar czf "${{ matrix.artifact }}.tar.gz" -- * shasum -a 256 "${{ matrix.artifact }}.tar.gz" > "${{ matrix.artifact }}.sha256" + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $dist = Join-Path $PWD 'dist' + New-Item -ItemType Directory -Path $dist -Force | Out-Null + $binaries = @('graphiq.exe', 'graphiq-mcp.exe', 'graphiq-bench.exe') + foreach ($binary in $binaries) { + $source = Join-Path $PWD "target\${{ matrix.target }}\release\$binary" + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "missing release binary: $source" + } + Copy-Item -LiteralPath $source -Destination (Join-Path $dist $binary) + } + $archiveName = '${{ matrix.artifact }}.zip' + $archive = Join-Path $dist $archiveName + Compress-Archive -Path (Join-Path $dist 'graphiq.exe'), (Join-Path $dist 'graphiq-mcp.exe'), (Join-Path $dist 'graphiq-bench.exe') -DestinationPath $archive -Force + $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $archiveName" | Set-Content -LiteralPath (Join-Path $dist '${{ matrix.artifact }}.sha256') + - uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} - path: | - dist/${{ matrix.artifact }}.tar.gz - dist/${{ matrix.artifact }}.sha256 + path: dist/* + if-no-files-found: error release: needs: [build] @@ -94,4 +118,6 @@ jobs: with: files: | artifacts/**/*.tar.gz + artifacts/**/*.zip + artifacts/**/*.sha256 generate_release_notes: true diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml new file mode 100644 index 0000000..9a3331f --- /dev/null +++ b/.github/workflows/windows-smoke.yml @@ -0,0 +1,174 @@ +name: Windows smoke + +# Validates the native Windows build, Windows path handling, setup/update +# integration points, and the MCP stdio protocol. GPU availability is not a +# property of the hosted runner, so the deterministic smoke path forces the +# documented CPU fallback while the release build still includes the gpu feature. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + smoke: + runs-on: windows-2022 + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build release (gpu feature) + run: cargo build --workspace --release --features gpu + + - name: Run workspace tests + run: cargo test --workspace --features gpu + + - name: Run Windows CLI and setup smoke + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $root = (Get-Location).Path + $graphiq = Join-Path $root 'target\release\graphiq.exe' + $mcp = Join-Path $root 'target\release\graphiq-mcp.exe' + $env:GRAPHIQ_DISABLE_GPU = '1' + + $version = & $graphiq --version + if ($LASTEXITCODE -ne 0 -or $version -notmatch '^graphiq ') { + throw "graphiq --version failed: $version" + } + + $fixture = Join-Path $env:RUNNER_TEMP 'graphiq Windows fixture with spaces' + Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path (Join-Path $fixture 'src') -Force | Out-Null + @' + pub struct RateLimiter { max: u32 } + impl RateLimiter { + pub fn check_rate(&self) -> bool { true } + pub fn reset(&self) {} + } + pub fn handle_request(rl: &RateLimiter) -> bool { rl.check_rate() } + '@ | Set-Content -LiteralPath (Join-Path $fixture 'src\lib.rs') -Encoding utf8 + + & git -C $fixture init --quiet + & git -C $fixture config user.email ci@example.invalid + & git -C $fixture config user.name 'GraphIQ CI' + & git -C $fixture add --all + & git -C $fixture commit --quiet -m init + if ($LASTEXITCODE -ne 0) { throw 'failed to create the Windows fixture repository' } + + $db = Join-Path $fixture '.graphiq\graphiq.db' + & $graphiq index $fixture --db $db + if ($LASTEXITCODE -ne 0) { throw 'graphiq index failed on Windows' } + + $search = (& $graphiq search 'rate limit' --db $db --top 3 | Out-String) + if ($LASTEXITCODE -ne 0 -or $search -notmatch 'RateLimiter') { + throw "Windows search did not find RateLimiter: $search" + } + + $status = (& $graphiq status --db $db | Out-String) + if ($LASTEXITCODE -ne 0 -or $status -notmatch 'GPU: unavailable \(disabled by GRAPHIQ_DISABLE_GPU\)') { + throw "Windows status did not report the CPU fallback: $status" + } + + # This exercises sibling graphiq-mcp.exe discovery, Windows command + # lookup, and the no-Unix-package-manager setup path. + & $graphiq setup --project $fixture --skip-index --persistent --harness claude-code + if ($LASTEXITCODE -ne 0) { throw 'graphiq setup failed on Windows' } + $configPath = Join-Path $fixture '.claude\.mcp.json' + $config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json + if ($null -eq $config.mcpServers.graphiq) { throw 'setup did not write the Claude Code MCP entry' } + $configuredProject = [string]$config.mcpServers.graphiq.args[0] + # Rust canonicalize may use the Windows extended-path prefix; both + # spellings identify the same project and are accepted by the MCP. + if ($configuredProject.StartsWith('\\?\')) { + $configuredProject = $configuredProject.Substring(4) + } + if ($configuredProject -ne $fixture) { throw "setup wrote the wrong Windows project path: $configuredProject" } + + $sync = (& $graphiq sync --project $fixture --harness claude-code | Out-String) + if ($LASTEXITCODE -ne 0 -or $sync -notmatch 'connected:') { + throw "graphiq sync did not verify the Windows MCP entry: $sync" + } + + function Read-McpJsonLine([System.Diagnostics.Process]$Process) { + $read = $Process.StandardOutput.ReadLineAsync() + if (-not $read.Wait(10000)) { throw 'timed out waiting for MCP response' } + if ($null -eq $read.Result) { throw 'MCP exited before returning a response' } + return ($read.Result | ConvertFrom-Json) + } + + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $mcp + $quotedFixture = '"' + $fixture.Replace('"', '\"') + '"' + $quotedDb = '"' + $db.Replace('"', '\"') + '"' + $psi.Arguments = "$quotedFixture --db $quotedDb" + $psi.WorkingDirectory = $root + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $mcpProcess = New-Object System.Diagnostics.Process + $mcpProcess.StartInfo = $psi + [void]$mcpProcess.Start() + try { + $mcpProcess.StandardInput.WriteLine('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}') + $mcpProcess.StandardInput.WriteLine('{"jsonrpc":"2.0","method":"notifications/initialized"}') + $mcpProcess.StandardInput.WriteLine('{"jsonrpc":"2.0","id":2,"method":"ping"}') + $mcpProcess.StandardInput.WriteLine('{"jsonrpc":"2.0","id":3,"method":"shutdown"}') + + $initialize = Read-McpJsonLine $mcpProcess + $ping = Read-McpJsonLine $mcpProcess + $shutdown = Read-McpJsonLine $mcpProcess + if ($initialize.id -ne 1 -or $initialize.result.serverInfo.name -ne 'graphiq') { throw 'invalid MCP initialize response on Windows' } + if ($ping.id -ne 2 -or $null -eq $ping.result) { throw 'invalid MCP ping response on Windows' } + if ($shutdown.id -ne 3) { throw 'invalid MCP shutdown response on Windows' } + } finally { + $mcpProcess.StandardInput.Close() + if (-not $mcpProcess.WaitForExit(10000)) { + $mcpProcess.Kill() + throw 'MCP process did not exit after stdin closed' + } + $mcpProcess.Dispose() + } + + - name: Test PowerShell installer with the local release archive + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $root = (Get-Location).Path + $stage = Join-Path $env:RUNNER_TEMP 'graphiq Windows package' + $installDir = Join-Path $env:RUNNER_TEMP 'graphiq Windows install' + Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $installDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $stage -Force | Out-Null + foreach ($binary in @('graphiq.exe', 'graphiq-mcp.exe', 'graphiq-bench.exe')) { + Copy-Item -LiteralPath (Join-Path $root "target\release\$binary") -Destination (Join-Path $stage $binary) + } + $archiveName = 'graphiq-x86_64-pc-windows-msvc.zip' + $archive = Join-Path $stage $archiveName + Compress-Archive -Path (Join-Path $stage 'graphiq.exe'), (Join-Path $stage 'graphiq-mcp.exe'), (Join-Path $stage 'graphiq-bench.exe') -DestinationPath $archive -Force + $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $archiveName" | Set-Content -LiteralPath ([IO.Path]::ChangeExtension($archive, 'sha256')) + + & (Join-Path $root 'install.ps1') -ArchivePath $archive -InstallDir $installDir -NoPathUpdate -Version 'ci' + if (-not (Test-Path -LiteralPath (Join-Path $installDir 'graphiq.exe'))) { + throw 'PowerShell installer did not install graphiq.exe' + } + $installedVersion = & (Join-Path $installDir 'graphiq.exe') --version + if ($LASTEXITCODE -ne 0 -or $installedVersion -notmatch '^graphiq ') { + throw "installed Windows binary failed: $installedVersion" + } + + & (Join-Path $root 'install.ps1') -Uninstall -InstallDir $installDir -NoPathUpdate + if (Test-Path -LiteralPath (Join-Path $installDir 'graphiq.exe')) { + throw 'PowerShell installer did not uninstall graphiq.exe' + } diff --git a/Cargo.lock b/Cargo.lock index 9ee9af0..5a19406 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1362,7 +1362,7 @@ dependencies = [ [[package]] name = "graphiq-bench" -version = "4.4.0" +version = "4.5.0" dependencies = [ "graphiq-core", "serde", @@ -1371,7 +1371,7 @@ dependencies = [ [[package]] name = "graphiq-cli" -version = "4.4.0" +version = "4.5.0" dependencies = [ "clap", "dirs", @@ -1383,7 +1383,7 @@ dependencies = [ [[package]] name = "graphiq-core" -version = "4.4.0" +version = "4.5.0" dependencies = [ "aho-corasick", "bincode", @@ -1428,7 +1428,7 @@ dependencies = [ [[package]] name = "graphiq-mcp" -version = "4.4.0" +version = "4.5.0" dependencies = [ "graphiq-core", "notify", diff --git a/README.md b/README.md index 97c02e5..cd15907 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ MIT License Linux smoke macOS smoke + Windows smoke

@@ -35,6 +36,18 @@ Or install a prebuilt binary on macOS or Linux: curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | bash ``` +On Windows, run the PowerShell installer: + +```powershell +$installer = Join-Path $env:TEMP "graphiq-install.ps1" +Invoke-WebRequest https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.ps1 -OutFile $installer +powershell -ExecutionPolicy Bypass -File $installer +Remove-Item $installer +``` + +The Windows installer places `graphiq.exe` and `graphiq-mcp.exe` in +`%LOCALAPPDATA%\GraphIQ\bin` and adds that directory to the user PATH. + ### Index and search ```bash diff --git a/crates/graphiq-bench/Cargo.toml b/crates/graphiq-bench/Cargo.toml index 5e36454..62612ce 100644 --- a/crates/graphiq-bench/Cargo.toml +++ b/crates/graphiq-bench/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphiq-bench" -version = "4.4.0" +version = "4.5.0" edition = "2021" [[bin]] diff --git a/crates/graphiq-cli/Cargo.toml b/crates/graphiq-cli/Cargo.toml index deb986e..87f725c 100644 --- a/crates/graphiq-cli/Cargo.toml +++ b/crates/graphiq-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphiq-cli" -version = "4.4.0" +version = "4.5.0" edition = "2021" [[bin]] diff --git a/crates/graphiq-cli/src/main.rs b/crates/graphiq-cli/src/main.rs index f5cfbb2..84fd076 100644 --- a/crates/graphiq-cli/src/main.rs +++ b/crates/graphiq-cli/src/main.rs @@ -1252,16 +1252,7 @@ struct DepCheck { } fn cmd_exists(cmd: &str) -> bool { - std::process::Command::new("which") - .arg(cmd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - || std::process::Command::new("command") - .args(["-v", cmd]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) + binary_on_path(cmd).is_some() } fn confirm(prompt: &str) -> bool { @@ -1276,6 +1267,15 @@ fn confirm(prompt: &str) -> bool { } fn check_build_dependencies() { + // A release install has no source-build dependencies. In particular, a + // Windows install uses the system MSVC runtime and DirectX 12 when a GPU + // is available; it must not try to run the Unix/Linux package-manager + // probes below. + if cfg!(windows) { + println!(" Windows runtime: bundled SQLite; optional GPU acceleration uses DirectX 12"); + return; + } + let is_macos = std::env::consts::OS == "macos"; let deps = [ @@ -1426,6 +1426,28 @@ fn check_build_dependencies() { } } +/// Escape a value for a TOML basic string. Windows paths contain backslashes, +/// which TOML treats as escape characters, so writing a raw `C:\\...` path +/// would produce an invalid Codex configuration. +fn toml_basic_string(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + ch if ch.is_control() => { + use std::fmt::Write; + let _ = write!(escaped, "\\u{:04x}", ch as u32); + } + ch => escaped.push(ch), + } + } + escaped +} + /// Strip JSONC comments (`// line` and `/* block */`) from text so it can be /// parsed by a strict JSON parser (serde_json). String literals are preserved — /// a `//` or `/*` inside a string value (e.g. a URL) is NOT treated as a @@ -1519,7 +1541,7 @@ fn parse_jsonc_config(content: &str) -> Option { /// correctly detect an existing graphiq entry, we prefer the file that already /// exists (`.jsonc` first), and create `.jsonc` when neither exists. fn opencode_config_path(home: &std::path::Path) -> std::path::PathBuf { - let dir = home.join(".config").join("opencode"); + let dir = opencode_config_dir(home); let jsonc = dir.join("opencode.jsonc"); let json = dir.join("opencode.json"); if jsonc.exists() { @@ -1531,6 +1553,39 @@ fn opencode_config_path(home: &std::path::Path) -> std::path::PathBuf { } } +/// Return OpenCode's per-user configuration directory on the current OS. +/// Linux/macOS use the XDG-style location already supported by GraphIQ; +/// Windows applications conventionally use `%APPDATA%` instead. +fn opencode_config_dir(home: &std::path::Path) -> std::path::PathBuf { + let base = if cfg!(windows) { + windows_roaming_dir(home) + } else { + home.join(".config") + }; + base.join("opencode") +} + +/// Resolve Windows' roaming application-data directory while keeping helpers +/// that accept an explicit home path deterministic in tests. The real home +/// directory uses `%APPDATA%` (which may be redirected); synthetic homes use +/// the conventional `AppData\Roaming` location beneath the supplied path. +fn windows_roaming_dir(home: &std::path::Path) -> std::path::PathBuf { + if let (Some(actual_home), Some(appdata)) = (dirs::home_dir(), std::env::var_os("APPDATA")) { + if actual_home == home { + return PathBuf::from(appdata); + } + } + home.join("AppData").join("Roaming") +} + +fn platform_config_dir(home: &std::path::Path) -> Option { + if cfg!(windows) { + Some(windows_roaming_dir(home)) + } else { + dirs::config_dir() + } +} + // ─── graphiq sync: verify harness attach + reconcile graphiq storage ───────── // // `graphiq sync` reports the TRUE attach state of every supported harness @@ -1685,7 +1740,7 @@ struct HarnessTarget { fn harness_targets(home: &std::path::Path, project: &std::path::Path) -> Vec { let mut out = Vec::new(); if let Some(claude_desktop) = - dirs::config_dir().map(|d| d.join("Claude").join("claude_desktop_config.json")) + platform_config_dir(home).map(|d| d.join("Claude").join("claude_desktop_config.json")) { out.push(HarnessTarget { name: "claude-desktop", @@ -1699,7 +1754,7 @@ fn harness_targets(home: &std::path::Path, project: &std::path::Path) -> Vec Vec Option { - if let Some(sibling) = which_graphiq() { - return Some(sibling); - } - // Fallback: PATH lookup. `which` is Unix; on Windows `where` is the tool. - let probe = if cfg!(windows) { "where" } else { "which" }; - std::process::Command::new(probe) - .arg("graphiq-mcp") - .output() - .ok() - .filter(|o| o.status.success()) - .and_then(|o| String::from_utf8(o.stdout).ok()) - .and_then(|s| { - let line = s.lines().next()?.trim().to_string(); - if line.is_empty() { - None + which_graphiq().or_else(|| binary_on_path("graphiq-mcp")) +} + +/// Re-enter the CLI using the exact executable that is currently running. +/// This matters for `sync --apply` when a user launched `graphiq.exe` by path +/// before adding its directory to PATH. +fn current_graphiq_command() -> std::process::Command { + std::env::current_exe() + .map(|path| std::process::Command::new(path)) + .unwrap_or_else(|_| { + std::process::Command::new(if cfg!(windows) { + "graphiq.exe" } else { - Some(PathBuf::from(line)) - } + "graphiq" + }) }) } @@ -1788,6 +1840,7 @@ fn cmd_sync(project: Option<&std::path::Path>, harness_filter: Option<&str>, app println!("╭──────────────────────────────────────────────╮"); println!("│ GraphIQ Sync │"); println!("╰──────────────────────────────────────────────╯"); + println!(" Project: {}", project_path.display()); // 1. Is graphiq-mcp reachable? This is the #1 attach failure. @@ -1873,7 +1926,8 @@ fn cmd_sync(project: Option<&std::path::Path>, harness_filter: Option<&str>, app println!(" Re-applying graphiq config to missing harnesses via setup..."); // setup is idempotent and scoped to installed harnesses; --skip-index // keeps it fast (sync never indexes). - let setup_status = std::process::Command::new("graphiq") + let mut setup_command = current_graphiq_command(); + let setup_status = setup_command .arg("setup") .arg("--project") .arg(&project_path) @@ -2135,10 +2189,15 @@ fn detect_signature( /// Resolve the full path for a harness's config-dir signal, given a home dir. /// Returns None if the signature has no config-dir signal. fn config_dir_path(home: &std::path::Path, sig: &HarnessSignature) -> Option { - if sig.config_dir.is_empty() { - None - } else { - Some(home.join(sig.config_dir)) + match sig.name { + // Claude Desktop stores its user config in %APPDATA% on Windows; + // keep the catalog's macOS-relative path for the other platforms. + "claude-desktop" if cfg!(windows) => platform_config_dir(home).map(|d| d.join("Claude")), + // OpenCode follows the same platform-specific roaming config root as + // setup/sync rather than the Unix ~/.config path in the catalog. + "opencode" => Some(opencode_config_dir(home)), + _ if sig.config_dir.is_empty() => None, + _ => Some(home.join(sig.config_dir)), } } @@ -2149,12 +2208,32 @@ fn binary_on_path(binary: &str) -> Option { return None; } let probe = if cfg!(windows) { "where" } else { "which" }; - std::process::Command::new(probe) + let from_probe = std::process::Command::new(probe) .arg(binary) .output() .ok() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| { + let line = s.lines().next()?.trim().to_string(); + if line.is_empty() { + None + } else { + Some(PathBuf::from(line)) + } + }); + if from_probe.is_some() || cfg!(windows) { + return from_probe; + } + + // Keep the old Unix fallback for minimal installations that have the + // shell builtin but not the standalone `which` utility. + std::process::Command::new("sh") + .args(["-c", "command -v \"$1\"", "graphiq-path", binary]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) .and_then(|s| { let line = s.lines().next()?.trim().to_string(); if line.is_empty() { @@ -2860,6 +2939,8 @@ fn cmd_setup( }, }; + let home_dir = dirs::home_dir().unwrap_or_else(|| std::path::Path::new(".").to_path_buf()); + println!(" Project: {}", project_path.display()); if ephemeral { println!(" Index storage: temp MCP database (--persistent opts into .graphiq)"); @@ -2869,19 +2950,16 @@ fn cmd_setup( println!(); let graphiq_bin = which_graphiq(); - if graphiq_bin.is_none() { - let found = std::process::Command::new("which") - .arg("graphiq-mcp") - .output() - .ok() - .filter(|o| o.status.success()); - if found.is_none() { - eprintln!(" error: graphiq-mcp not found on PATH."); - eprintln!(" Install with: cargo install --path . --bin graphiq-mcp"); + if graphiq_bin.is_none() && binary_on_path("graphiq-mcp").is_none() { + eprintln!(" error: graphiq-mcp not found on PATH."); + eprintln!(" Install with: cargo install --path . --bin graphiq-mcp"); + if cfg!(windows) { + eprintln!(" Or: download and run install.ps1 from the GraphIQ release."); + } else { eprintln!(" Or: curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | bash"); - eprintln!(" Then re-run setup."); - std::process::exit(1); } + eprintln!(" Then re-run setup."); + std::process::exit(1); } check_build_dependencies(); @@ -2921,18 +2999,10 @@ fn cmd_setup( } }; - let _binary_on_path = |name: &str| -> bool { - std::process::Command::new("which") - .arg(name) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - }; - // Claude Desktop if should_configure("claude-desktop") { - let claude_config = - dirs::config_dir().map(|d| d.join("Claude").join("claude_desktop_config.json")); + let claude_config = platform_config_dir(&home_dir) + .map(|d| d.join("Claude").join("claude_desktop_config.json")); if let Some(ref config_path) = claude_config { if config_path.exists() || config_path.parent().map_or(false, |p| p.exists()) { @@ -2995,18 +3065,16 @@ fn cmd_setup( // and merges them. Write to whichever already exists (prefer .jsonc) so // we don't spawn a second file, and parse JSONC safely so a commented // config is preserved rather than destroyed. - let opencode_config = dirs::home_dir() - .map(|h| opencode_config_path(&h)) - .and_then(|p| { - // Only proceed if the opencode config directory itself exists — - // creating a brand-new opencode.jsonc in a missing dir would - // imply opencode is installed when it may not be. - if p.parent().map_or(false, |d| d.exists()) { - Some(p) - } else { - None - } - }); + let opencode_config = Some(opencode_config_path(&home_dir)).and_then(|p| { + // Only proceed if the opencode config directory itself exists — + // creating a brand-new opencode.jsonc in a missing dir would + // imply opencode is installed when it may not be. + if p.parent().map_or(false, |d| d.exists()) { + Some(p) + } else { + None + } + }); if let Some(ref config_path) = opencode_config { // Wrapped in a labeled block so recoverable opencode errors @@ -3112,13 +3180,14 @@ fn cmd_setup( // Codex CLI if should_configure("codex") { - let codex_config = dirs::home_dir().map(|d| d.join(".codex").join("config.toml")); + let codex_config = Some(home_dir.join(".codex").join("config.toml")); if let Some(ref config_path) = codex_config { // Labeled block so a codex read failure skips ONLY codex, not the // rest of cmd_setup (Hermes/Cursor/Windsurf/Gemini/Aider/index). 'codex: { let project_str = project_path.display().to_string(); + let project_arg = toml_basic_string(&project_str); let args_suffix = if ephemeral { ", \"--ephemeral\"" } else { "" }; let (content, written) = if config_path.exists() { @@ -3132,14 +3201,15 @@ fn cmd_setup( || t.starts_with("[mcp_servers.graphiq.") || t.starts_with("[mcp_servers.graphiq ") }); - let same_project = existing.contains(&project_str); + let same_project = + existing.contains(&project_str) || existing.contains(&project_arg); if has_graphiq && same_project { (existing, false) } else { let mut cleaned = existing; let section = format!( "\n[mcp_servers.graphiq]\ncommand = \"graphiq-mcp\"\nargs = [\"{}\"{}]\nenabled = true\n", - project_str, args_suffix + project_arg, args_suffix ); cleaned.push_str(§ion); (cleaned, true) @@ -3154,7 +3224,7 @@ fn cmd_setup( } else { let section = format!( "[mcp_servers.graphiq]\ncommand = \"graphiq-mcp\"\nargs = [\"{}\"{}]\nenabled = true\n", - project_str, args_suffix + project_arg, args_suffix ); (section, true) }; @@ -3299,7 +3369,7 @@ fn cmd_setup( // Gemini CLI if should_configure("gemini") { - let gemini_config = dirs::home_dir().map(|d| d.join(".gemini").join("settings.json")); + let gemini_config = Some(home_dir.join(".gemini").join("settings.json")); if let Some(ref config_path) = gemini_config { let project_str = project_path.display().to_string(); let mut cmd = vec!["graphiq-mcp".to_string(), project_str.clone()]; @@ -3587,22 +3657,20 @@ fn write_agents_md(graphiq_dir: &std::path::Path) { } fn which_graphiq() -> Option { - let graphiq_mcp = std::env::current_exe().ok()?; - let bin_name = graphiq_mcp.file_name()?.to_str()?.to_string(); - if bin_name == "graphiq" { - let mut p = graphiq_mcp.clone(); - p.set_file_name("graphiq-mcp"); - if p.exists() { - return Some(p); - } - if let Some(parent) = graphiq_mcp.parent() { - let alt = parent.join("graphiq-mcp"); - if alt.exists() { - return Some(alt); - } - } + let current_exe = std::env::current_exe().ok()?; + let bin_name = current_exe.file_stem()?.to_str()?; + if bin_name != "graphiq" { + return None; } - None + + let parent = current_exe.parent()?; + let sibling_name = if cfg!(windows) { + "graphiq-mcp.exe" + } else { + "graphiq-mcp" + }; + let sibling = parent.join(sibling_name); + sibling.exists().then_some(sibling) } fn cmd_context(symbol_name: &str, db_path: &std::path::Path) { @@ -4815,7 +4883,7 @@ fn cmd_update() { } }; - // fetch_latest_version returns a v-prefixed tag (e.g. "v4.3.3"). + // fetch_latest_version returns a v-prefixed tag (e.g. "v4.5.0"). let latest_version = latest_tag.strip_prefix('v').unwrap_or(&latest_tag); if latest_version == current_version { @@ -4824,6 +4892,33 @@ fn cmd_update() { } println!(" Update available: {} -> {}", current_version, latest_tag); + if cfg!(windows) { + let install_dir = match std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(|parent| parent.to_path_buf())) + { + Some(path) => path, + None => { + eprintln!(" error: could not determine the current install directory"); + std::process::exit(1); + } + }; + + println!(" Launching the official PowerShell installer..."); + match launch_windows_update(&latest_version, &install_dir) { + Ok(()) => { + println!( + " updater started; it will replace the binaries after this process exits." + ); + return; + } + Err(e) => { + eprintln!(" error: failed to launch PowerShell installer: {e}"); + std::process::exit(1); + } + } + } + println!(" Running the official install script..."); // Delegate to install.sh, which handles download, SHA-256 verification, @@ -4858,10 +4953,8 @@ fn cmd_update() { // update takes effect for MCP servers and connector configs immediately. println!(); println!(" Re-linking harnesses (graphiq sync --apply)..."); - match std::process::Command::new("graphiq") - .args(["sync", "--apply"]) - .status() - { + let mut sync_command = current_graphiq_command(); + match sync_command.args(["sync", "--apply"]).status() { Ok(s) if s.success() => println!(" harnesses re-linked."), Ok(s) => eprintln!( " warning: graphiq sync exited {} - run `graphiq sync --apply` manually", @@ -4874,22 +4967,88 @@ fn cmd_update() { println!(" Done."); } -fn fetch_latest_version(_client: &str) -> Result { - let output = std::process::Command::new("curl") +const WINDOWS_INSTALLER_URL: &str = + "https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.ps1"; + +fn powershell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +/// Start the Windows updater without waiting for it. Windows keeps an +/// executable locked while it is running, so the installer waits for this +/// process ID before atomically replacing the installed binaries. +fn launch_windows_update(version: &str, install_dir: &std::path::Path) -> Result<(), String> { + let command = format!( + "$script = (Invoke-WebRequest -UseBasicParsing -Uri {}).Content; & ([scriptblock]::Create($script)) -Version {} -InstallDir {} -WaitForPid {}", + powershell_single_quote(WINDOWS_INSTALLER_URL), + powershell_single_quote(version), + powershell_single_quote(&install_dir.display().to_string()), + std::process::id(), + ); + + std::process::Command::new("powershell.exe") .args([ - "-fsSL", - "--max-time", - "15", - "https://api.github.com/repos/aaf2tbz/graphiq/releases/latest", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + &command, ]) - .output() - .map_err(|e| format!("curl failed: {e}"))?; + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn fetch_latest_version(_client: &str) -> Result { + let output = (if cfg!(windows) { + std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "(Invoke-RestMethod -Uri 'https://api.github.com/repos/aaf2tbz/graphiq/releases/latest').tag_name", + ]) + .output() + } else { + std::process::Command::new("curl") + .args([ + "-fsSL", + "--max-time", + "15", + "https://api.github.com/repos/aaf2tbz/graphiq/releases/latest", + ]) + .output() + }) + .map_err(|e| { + if cfg!(windows) { + format!("PowerShell failed: {e}") + } else { + format!("curl failed: {e}") + } + })?; if !output.status.success() { return Err("failed to fetch latest release info".into()); } - let body = String::from_utf8_lossy(&output.stdout); + parse_release_tag(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_release_tag(body: &str) -> Result { + let trimmed = body.trim(); + if trimmed.starts_with('v') && !trimmed.contains('{') && !trimmed.contains('"') { + return Ok(trimmed.to_string()); + } + + if let Ok(value) = serde_json::from_str::(trimmed) { + if let Some(version) = value.get("tag_name").and_then(|tag| tag.as_str()) { + if !version.is_empty() { + return Ok(version.to_string()); + } + } + } + for line in body.lines() { if line.contains("\"tag_name\"") { let version = line @@ -5028,7 +5187,7 @@ mod tests { .unwrap() .as_nanos() )); - let dir = tmp.join(".config").join("opencode"); + let dir = opencode_config_dir(&tmp); std::fs::create_dir_all(&dir).unwrap(); // Neither exists -> default to .jsonc (modern canonical). @@ -5455,4 +5614,34 @@ mod tests { // Unix epoch itself. assert_eq!(format_unix_ts_iso(0), "1970-01-01T00:00:00Z"); } + + #[test] + fn toml_basic_string_escapes_windows_paths() { + assert_eq!( + toml_basic_string(r#"C:\Users\Avery\GraphIQ"#), + r#"C:\\Users\\Avery\\GraphIQ"# + ); + assert_eq!( + toml_basic_string("line\nnext\tvalue"), + "line\\nnext\\tvalue" + ); + } + + #[test] + fn parse_release_tag_accepts_json_and_powershell_output() { + assert_eq!( + parse_release_tag(r#"{ "tag_name": "v4.5.0" }"#).unwrap(), + "v4.5.0" + ); + assert_eq!(parse_release_tag("v4.5.0\r\n").unwrap(), "v4.5.0"); + assert!(parse_release_tag("not a release").is_err()); + } + + #[test] + fn powershell_single_quote_escapes_embedded_quotes() { + assert_eq!( + powershell_single_quote("C:\\Avery's GraphIQ"), + "'C:\\Avery''s GraphIQ'" + ); + } } diff --git a/crates/graphiq-core/Cargo.toml b/crates/graphiq-core/Cargo.toml index 8c38623..af4bca2 100644 --- a/crates/graphiq-core/Cargo.toml +++ b/crates/graphiq-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphiq-core" -version = "4.4.0" +version = "4.5.0" edition = "2021" [dependencies] diff --git a/crates/graphiq-core/src/index.rs b/crates/graphiq-core/src/index.rs index 17a33d5..2c48e65 100644 --- a/crates/graphiq-core/src/index.rs +++ b/crates/graphiq-core/src/index.rs @@ -99,6 +99,18 @@ impl ParsedFile { } } +/// Store project-relative paths with the same slash-separated representation +/// on every platform. This keeps persisted paths, import resolution, search +/// filters, and user-facing results portable between Windows and Unix. +fn stored_relative_path(path: &Path) -> String { + let path = path.to_string_lossy(); + if cfg!(windows) { + path.replace('\\', "/") + } else { + path.into_owned() + } +} + impl<'a> Indexer<'a> { pub fn new(db: &'a GraphDb) -> Self { Self { @@ -235,10 +247,12 @@ impl<'a> Indexer<'a> { let current_paths: HashSet = files .iter() .filter_map(|path| path.strip_prefix(root).ok()) - .map(|path| path.to_string_lossy().into_owned()) + .map(stored_relative_path) .collect(); for path in self.db.file_paths()? { - if !current_paths.contains(&path) && self.db.delete_file(&path)? { + if !current_paths.contains(&stored_relative_path(Path::new(&path))) + && self.db.delete_file(&path)? + { files_deleted += 1; } } @@ -295,7 +309,7 @@ impl<'a> Indexer<'a> { let file_plans: Vec = file_data .iter() .map(|(rel_path, _, _, hash, _, _)| { - let path_str = rel_path.to_string_lossy(); + let path_str = stored_relative_path(rel_path); let existing = self.db.get_file_by_path(&path_str)?; let changed = existing .as_ref() @@ -334,7 +348,7 @@ impl<'a> Indexer<'a> { return ParsedFile::empty(); } - let path_str = rel_path.to_string_lossy(); + let path_str = stored_relative_path(rel_path); let chunker = get_chunker(*lang); let mut result = chunker.parse(source, &path_str); // Call extraction only needs the tree during this worker call. @@ -378,7 +392,7 @@ impl<'a> Indexer<'a> { .zip(file_plans.iter()) .zip(parsed_files.iter()) { - let path_str = rel_path.to_string_lossy(); + let path_str = stored_relative_path(rel_path); if let Some(ref f) = plan.existing { if !plan.changed { diff --git a/crates/graphiq-mcp/Cargo.toml b/crates/graphiq-mcp/Cargo.toml index 26499e2..3cd8948 100644 --- a/crates/graphiq-mcp/Cargo.toml +++ b/crates/graphiq-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphiq-mcp" -version = "4.4.0" +version = "4.5.0" edition = "2021" [[bin]] diff --git a/docs/contributing.md b/docs/contributing.md index 81ba59b..7109885 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -49,8 +49,10 @@ The CLI surfaces index/search/blast plus lifecycle commands: `setup`, `sync` - **`macos-smoke.yml`** — builds natively on Apple Silicon, proves Metal index acceleration and hybrid search scoring, then verifies CPU fallback and result parity. +- **`windows-smoke.yml`** — builds natively with MSVC, runs the CLI and MCP + protocol on Windows paths, and verifies the PowerShell installer. - **`release.yml`** — cross-builds macOS (arm64 + x86_64) and Linux (x86_64 + - aarch64) releases with SHA-256 checksums. + aarch64) plus Windows (x86_64 MSVC) releases with SHA-256 checksums. - **`auto-release.yml`** — tags the next version on every push to `main`. For Apple Silicon search measurements, build with the GPU feature and run: diff --git a/docs/install.md b/docs/install.md index c34133d..827326a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,6 +1,6 @@ # Install -GraphIQ runs on macOS (Apple Silicon + Intel), Linux (x86_64 + aarch64), and Windows (builds from source). +GraphIQ runs on macOS (Apple Silicon + Intel), Linux (x86_64 + aarch64), and Windows (x86_64). ## Homebrew @@ -17,6 +17,21 @@ curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | b The script downloads a prebuilt binary for your platform, verifies the SHA-256 checksum, and places `graphiq` + `graphiq-mcp` on your PATH. On Linux it detects a missing Vulkan loader and offers to install one (optional — GraphIQ runs on CPU without it). +## Windows (PowerShell) + +```powershell +$installer = Join-Path $env:TEMP "graphiq-install.ps1" +Invoke-WebRequest https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.ps1 -OutFile $installer +powershell -ExecutionPolicy Bypass -File $installer +Remove-Item $installer +``` + +The installer downloads the `x86_64-pc-windows-msvc` release, verifies its +SHA-256 checksum, installs `graphiq.exe`, `graphiq-mcp.exe`, and +`graphiq-bench.exe` under `%LOCALAPPDATA%\GraphIQ\bin`, and adds that directory +to the current user's PATH. Open a new terminal after installation. To remove +it, run the same script with `-Uninstall`. + ## From Source ```bash @@ -25,7 +40,7 @@ cd graphiq cargo build --release ``` -For GPU acceleration add `--features gpu`. On macOS this selects the system Metal backend (no Vulkan or extra runtime is required); on Linux it uses Vulkan when a compatible loader/GPU is available and otherwise falls back to CPU. Indexing and large search score batches use the GPU automatically; small searches stay on CPU to avoid transfer overhead. Set `GRAPHIQ_DISABLE_GPU=1` to force the fallback path. +For GPU acceleration add `--features gpu`. On macOS this selects the system Metal backend (no Vulkan or extra runtime is required); on Linux it uses Vulkan when a compatible loader/GPU is available; on Windows it uses DirectX 12 when available. Every platform falls back to CPU when GPU initialization is unavailable. Indexing and large search score batches use the GPU automatically; small searches stay on CPU to avoid transfer overhead. Set `GRAPHIQ_DISABLE_GPU=1` to force the fallback path. ## Requirements @@ -34,6 +49,9 @@ For GPU acceleration add `--features gpu`. On macOS this selects the system Meta - `pkg-config` - SQLite is bundled. +Prebuilt Windows releases do not require Rust, a C compiler, or `pkg-config`. +Windows source builds require the Visual Studio C++ build tools. + ## Platform Support | Platform | Status | Notes | @@ -42,7 +60,7 @@ For GPU acceleration add `--features gpu`. On macOS this selects the system Meta | **macOS** (Intel) | ✅ Supported | prebuilt release; Metal acceleration depends on the OS/GPU | | **Linux** (x86_64) | ✅ Supported | prebuilt release; [smoke-tested in CI](https://github.com/aaf2tbz/graphiq/actions/workflows/linux-smoke.yml) with zero Vulkan installed | | **Linux** (aarch64) | ✅ Built | prebuilt release; built + RPATH-asserted in CI | -| **Windows** | ⚠️ Builds | not prebuilt; `which`/path checks fall back to `where` | +| **Windows** | ✅ Supported | prebuilt x86_64 release; DirectX 12 when available, CPU fallback otherwise | ## Uninstall @@ -51,3 +69,12 @@ curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | b ``` This removes `graphiq`, `graphiq-mcp`, and `graphiq-bench`. Project-local `.graphiq/` indexes are left in place (delete them manually if desired). + +On Windows: + +```powershell +$installer = Join-Path $env:TEMP "graphiq-install.ps1" +Invoke-WebRequest https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.ps1 -OutFile $installer +powershell -ExecutionPolicy Bypass -File $installer -Uninstall +Remove-Item $installer +``` diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..22c2765 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,228 @@ +# GraphIQ Windows installer +# +# Usage: +# powershell -ExecutionPolicy Bypass -File .\install.ps1 +# powershell -ExecutionPolicy Bypass -File .\install.ps1 -Version 4.5.0 +# powershell -ExecutionPolicy Bypass -File .\install.ps1 -Uninstall +# +# The default install location is %LOCALAPPDATA%\GraphIQ\bin. The installer +# adds that directory to the current user's PATH without requiring elevation. +# -ArchivePath and -NoPathUpdate are intended for CI and offline installs. + +[CmdletBinding()] +param( + [string]$Version = "", + [string]$InstallDir = "", + [string]$ArchivePath = "", + [switch]$Uninstall, + [switch]$NoPathUpdate, + [int]$WaitForPid = 0 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +# Windows PowerShell 5.1 may default to older TLS versions; GitHub requires TLS 1.2. +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +} catch { + # PowerShell 7/.NET Core does not need this compatibility setting. +} + +$Repository = "aaf2tbz/GraphIQ" +$Target = "x86_64-pc-windows-msvc" +$BinaryNames = @("graphiq.exe", "graphiq-mcp.exe", "graphiq-bench.exe") +$ApiHeaders = @{ "User-Agent" = "GraphIQ installer" } + +if ([string]::IsNullOrWhiteSpace($InstallDir)) { + $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) + if ([string]::IsNullOrWhiteSpace($localAppData)) { + $localAppData = $env:LOCALAPPDATA + } + if ([string]::IsNullOrWhiteSpace($localAppData)) { + throw "Could not determine the Windows local application-data directory. Pass -InstallDir explicitly." + } + $InstallDir = Join-Path $localAppData "GraphIQ\bin" +} +$InstallDir = [IO.Path]::GetFullPath($InstallDir) + +function Write-Info([string]$Message) { + Write-Host " [GraphIQ] $Message" +} + +function Find-ReleaseAsset($Release, [string]$Name) { + @($Release.assets | Where-Object { $_.name -eq $Name }) | Select-Object -First 1 +} + +function Get-Release([string]$ReleaseVersion) { + if ([string]::IsNullOrWhiteSpace($ReleaseVersion)) { + return Invoke-RestMethod -Uri "https://api.github.com/repos/$Repository/releases/latest" -Headers $ApiHeaders + } + + $normalized = $ReleaseVersion.Trim() + if ($normalized.StartsWith("v")) { + $normalized = $normalized.Substring(1) + } + return Invoke-RestMethod -Uri "https://api.github.com/repos/$Repository/releases/tags/v$normalized" -Headers $ApiHeaders +} + +function Normalize-PathForCompare([string]$Path) { + return ($Path.TrimEnd('\')).TrimEnd('/') +} + +function Add-InstallDirToUserPath { + if ($NoPathUpdate) { + return + } + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $parts = @() + if (-not [string]::IsNullOrWhiteSpace($userPath)) { + $parts = @($userPath -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + + $normalizedInstallDir = Normalize-PathForCompare $InstallDir + $alreadyPresent = @($parts | Where-Object { + (Normalize-PathForCompare $_) -ieq $normalizedInstallDir + }).Count -gt 0 + + if (-not $alreadyPresent) { + $parts += $InstallDir + [Environment]::SetEnvironmentVariable("Path", ($parts -join ';'), "User") + $env:Path = "$InstallDir;$env:Path" + Write-Info "added $InstallDir to the current user's PATH" + } +} + +function Remove-InstallDirFromUserPath { + if ($NoPathUpdate) { + return + } + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ([string]::IsNullOrWhiteSpace($userPath)) { + return + } + + $normalizedInstallDir = Normalize-PathForCompare $InstallDir + $parts = @($userPath -split ';' | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) -and + (Normalize-PathForCompare $_) -ine $normalizedInstallDir + }) + [Environment]::SetEnvironmentVariable("Path", ($parts -join ';'), "User") +} + +if ($Uninstall) { + foreach ($binary in $BinaryNames) { + $destination = Join-Path $InstallDir $binary + if (Test-Path -LiteralPath $destination) { + Remove-Item -LiteralPath $destination -Force + Write-Info "removed $destination" + } + } + Remove-InstallDirFromUserPath + Write-Info "uninstall complete" + exit 0 +} + +$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("graphiq-install-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null + +try { + $release = $null + if ([string]::IsNullOrWhiteSpace($ArchivePath)) { + $release = Get-Release $Version + $tagName = [string]$release.tag_name + if ([string]::IsNullOrWhiteSpace($tagName)) { + throw "GitHub release did not contain a tag name." + } + $Version = $tagName.TrimStart('v') + + $archiveName = "graphiq-$Target.zip" + $archiveAsset = Find-ReleaseAsset $release $archiveName + if ($null -eq $archiveAsset) { + throw "Release v$Version does not contain $archiveName." + } + + $archiveFile = Join-Path $tempRoot $archiveName + Write-Info "downloading GraphIQ v$Version" + Invoke-WebRequest -UseBasicParsing -Uri $archiveAsset.browser_download_url -Headers $ApiHeaders -OutFile $archiveFile + + $checksumName = [IO.Path]::ChangeExtension($archiveName, "sha256") + $checksumAsset = Find-ReleaseAsset $release $checksumName + if ($null -ne $checksumAsset) { + Invoke-WebRequest -UseBasicParsing -Uri $checksumAsset.browser_download_url -Headers $ApiHeaders -OutFile (Join-Path $tempRoot $checksumName) + } + } else { + $archiveFile = (Resolve-Path -LiteralPath $ArchivePath).Path + $archiveName = Split-Path -Leaf $archiveFile + if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = "local archive" + } + Write-Info "installing $archiveName" + } + + $checksumFile = [IO.Path]::ChangeExtension($archiveFile, "sha256") + $expectedHash = $null + if (Test-Path -LiteralPath $checksumFile) { + $checksumText = (Get-Content -LiteralPath $checksumFile -Raw).TrimStart([char]0xFEFF) + $pattern = '(?im)^\s*([0-9a-fA-F]{64})\s+\*?' + [Regex]::Escape($archiveName) + '\s*$' + $match = [Regex]::Match($checksumText, $pattern) + if ($match.Success) { + $expectedHash = $match.Groups[1].Value + } + } + if ([string]::IsNullOrWhiteSpace($expectedHash) -and $null -ne $release) { + $archiveAsset = Find-ReleaseAsset $release $archiveName + if ($null -ne $archiveAsset -and -not [string]::IsNullOrWhiteSpace([string]$archiveAsset.digest)) { + $expectedHash = ([string]$archiveAsset.digest) -replace '^sha256:', '' + } + } + if ([string]::IsNullOrWhiteSpace($expectedHash)) { + throw "No SHA-256 checksum was available for $archiveName; refusing to install it." + } + + $actualHash = (Get-FileHash -LiteralPath $archiveFile -Algorithm SHA256).Hash + if ($actualHash -ine $expectedHash.Trim()) { + throw "SHA-256 checksum mismatch for $archiveName. Expected $expectedHash, got $actualHash." + } + Write-Info "checksum verified" + + $extractDir = Join-Path $tempRoot "extracted" + Expand-Archive -LiteralPath $archiveFile -DestinationPath $extractDir -Force + foreach ($binary in $BinaryNames) { + $source = Join-Path $extractDir $binary + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Archive is missing required binary $binary." + } + } + + if ($WaitForPid -gt 0) { + $parent = Get-Process -Id $WaitForPid -ErrorAction SilentlyContinue + if ($null -ne $parent) { + Write-Info "waiting for process $WaitForPid to exit before replacing locked binaries" + Wait-Process -Id $WaitForPid + } + Start-Sleep -Milliseconds 500 + } + + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + foreach ($binary in $BinaryNames) { + $source = Join-Path $extractDir $binary + $destination = Join-Path $InstallDir $binary + $temporaryDestination = "$destination.tmp.$PID" + if (Test-Path -LiteralPath $temporaryDestination) { + Remove-Item -LiteralPath $temporaryDestination -Force + } + Copy-Item -LiteralPath $source -Destination $temporaryDestination -Force + Move-Item -LiteralPath $temporaryDestination -Destination $destination -Force + Write-Info "$binary -> $destination" + } + + Add-InstallDirToUserPath + Write-Info "installed GraphIQ v$Version" + Write-Host " Open a new terminal for PATH changes to take effect." +} finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/integrations/pi/graphiq-pi.ts b/integrations/pi/graphiq-pi.ts index 0de3e3f..83cb547 100644 --- a/integrations/pi/graphiq-pi.ts +++ b/integrations/pi/graphiq-pi.ts @@ -24,6 +24,9 @@ import { join } from "node:path"; // Resolve the graphiq binary: explicit override, then PATH lookup, then sibling. const GRAPHIQ_BIN = process.env.GRAPHIQ_BIN ?? "graphiq"; +const INSTALL_HINT = process.platform === "win32" + ? "GraphIQ not installed. Run the PowerShell installer from https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.ps1" + : "GraphIQ not installed. Run: curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | bash"; function graphiq(ctx: any, args: string[], opts: { timeoutMs?: number } = {}): Promise { return new Promise((resolve) => { @@ -81,7 +84,7 @@ export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { if (!isGraphiqInstalled()) { ctx.ui.notify( - "GraphIQ not installed. Run: curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | bash", + INSTALL_HINT, "info", ); return; @@ -185,7 +188,7 @@ export default function (pi: ExtensionAPI) { handler: async (args, ctx) => { if (!isGraphiqInstalled()) { ctx.ui.notify( - "GraphIQ not installed. Run: curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | bash", + INSTALL_HINT, "warning", ); return; diff --git a/signet-plugin/integration.json b/signet-plugin/integration.json index d911598..afd1d64 100644 --- a/signet-plugin/integration.json +++ b/signet-plugin/integration.json @@ -1,6 +1,6 @@ { "signetSourceInstallRev": "fa6c1a3", - "graphiqVersion": "4.3.3", + "graphiqVersion": "4.5.0", "pluginApi": "1.x", "minSignetVersion": "0.103.0", "updated": "2026-06-22" diff --git a/signet-plugin/manifest.json b/signet-plugin/manifest.json index 4f0b2cc..e2a2c08 100644 --- a/signet-plugin/manifest.json +++ b/signet-plugin/manifest.json @@ -1,7 +1,7 @@ { "id": "signet.graphiq", "name": "GraphIQ Code Retrieval", - "version": "4.3.3", + "version": "4.5.0", "publisher": "aaf2tbz", "description": "Optional verified managed plugin for fast local structural code retrieval through GraphIQ.", "runtime": {