#Requires -Version 5.1 <# .SYNOPSIS Installs Spokes into Cursor: runtime + app files + MCP entry + skill. .DESCRIPTION Fixes the common Windows pain points: - Node 24+ often fails to install better-sqlite3 without Visual Studio C++ tools. The installer prefers Node 20-22 and, if needed, downloads a portable Node 22 runtime. - Bundles the public GitHub OAuth Client ID so users only click "Connect GitHub". .EXAMPLE .\scripts\install.ps1 .EXAMPLE irm https://raw.githubusercontent.com/Dente22/Spokes/main/scripts/install.ps1 | iex #> [CmdletBinding()] param( [string]$InstallDir = "", [string]$RepoUrl = "https://github.com/Dente22/Spokes.git", [string]$Branch = "main", [string]$GithubClientId = "Ov23liNwS6P8EQ3hxViL", [string]$PortableNodeVersion = "22.18.0", [switch]$SkipNpm, [switch]$NoOpen ) $ErrorActionPreference = "Stop" # Resolved by Ensure-NodeRuntime and used for npm + mcp.json $script:NodeExe = $null $script:NpmCmd = $null function Write-Step([string]$Message) { Write-Host "" Write-Host "==> $Message" -ForegroundColor Cyan } function Write-Ok([string]$Message) { Write-Host " $Message" -ForegroundColor Green } function Write-Warn([string]$Message) { Write-Host " $Message" -ForegroundColor Yellow } function Test-IsSpokesRepo([string]$Path) { $pkg = Join-Path $Path "package.json" if (-not (Test-Path $pkg)) { return $false } try { $json = Get-Content -Raw -Path $pkg | ConvertFrom-Json return ($json.name -eq "spokes") } catch { return $false } } function Get-NodeMajorFromExe([string]$Exe) { if (-not (Test-Path $Exe)) { return $null } $ver = (& $Exe -v) -replace "^v", "" if ($ver -match "^(\d+)") { return [int]$Matches[1] } return $null } function Get-PortableNodeRoot { return Join-Path $env:LOCALAPPDATA "SpokesRuntime\node-v$PortableNodeVersion-win-x64" } function Install-PortableNode22 { Write-Step "Installing portable Node.js $PortableNodeVersion (recommended for Windows)" $root = Get-PortableNodeRoot $nodeExe = Join-Path $root "node.exe" if (Test-Path $nodeExe) { Write-Ok "Already present: $nodeExe" return $root } $zipName = "node-v$PortableNodeVersion-win-x64.zip" $zipUrl = "https://nodejs.org/dist/v$PortableNodeVersion/$zipName" $tmpZip = Join-Path $env:TEMP $zipName $tmpExtract = Join-Path $env:TEMP "spokes-node-extract" Write-Ok "Downloading $zipUrl" Invoke-WebRequest -Uri $zipUrl -OutFile $tmpZip -UseBasicParsing if (Test-Path $tmpExtract) { Remove-Item -Recurse -Force $tmpExtract } Expand-Archive -Path $tmpZip -DestinationPath $tmpExtract -Force $inner = Get-ChildItem $tmpExtract | Select-Object -First 1 if (-not $inner) { throw "Failed to extract portable Node.js" } $parent = Split-Path -Parent $root New-Item -ItemType Directory -Force -Path $parent | Out-Null if (Test-Path $root) { Remove-Item -Recurse -Force $root } Move-Item $inner.FullName $root Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $tmpExtract -ErrorAction SilentlyContinue Write-Ok "Portable Node ready: $root" return $root } function Ensure-NodeRuntime { Write-Step "Checking Node.js runtime" $portableExe = Join-Path (Get-PortableNodeRoot) "node.exe" if (Test-Path $portableExe) { $script:NodeExe = $portableExe $script:NpmCmd = Join-Path (Get-PortableNodeRoot) "npm.cmd" Write-Ok "Using portable Node $(& $script:NodeExe -v)" return } $systemNode = Get-Command node -ErrorAction SilentlyContinue if ($systemNode) { $major = Get-NodeMajorFromExe $systemNode.Source # better-sqlite3 frequently lacks prebuilds for Node 24+ on Windows and then # requires Visual Studio C++ Build Tools. Prefer 20-22. if ($null -ne $major -and $major -ge 20 -and $major -lt 24) { $script:NodeExe = $systemNode.Source $npm = Get-Command npm -ErrorAction SilentlyContinue if ($npm) { $script:NpmCmd = $npm.Source } else { $script:NpmCmd = Join-Path (Split-Path $script:NodeExe) "npm.cmd" } Write-Ok "Using system Node $(& $script:NodeExe -v)" return } if ($null -ne $major) { Write-Warn "System Node v$major detected - may fail building better-sqlite3 on Windows." Write-Warn "Downloading portable Node $PortableNodeVersion instead (no Visual Studio needed)." } } else { Write-Warn "Node.js not found on PATH - downloading portable Node $PortableNodeVersion." } $root = Install-PortableNode22 $script:NodeExe = Join-Path $root "node.exe" $script:NpmCmd = Join-Path $root "npm.cmd" Write-Ok "Using portable Node $(& $script:NodeExe -v)" } function Resolve-InstallDir { if ($InstallDir) { return [System.IO.Path]::GetFullPath($InstallDir) } if ($PSScriptRoot) { $repoRoot = Split-Path -Parent $PSScriptRoot if (Test-IsSpokesRepo $repoRoot) { Write-Ok "Using existing checkout: $repoRoot" return $repoRoot } } $fallback = Join-Path $env:LOCALAPPDATA "Spokes" Write-Ok "Will install to: $fallback" return $fallback } function Ensure-AppFiles([string]$Dir) { Write-Step "Installing Spokes files" if (Test-IsSpokesRepo $Dir) { if (Test-Path (Join-Path $Dir ".git")) { try { Push-Location $Dir git fetch --quiet origin $Branch 2>$null git checkout --quiet $Branch 2>$null git pull --ff-only --quiet origin $Branch 2>$null Write-Ok "Updated from git ($Branch)" } catch { Write-Warn "Git update skipped ($($_.Exception.Message))" } finally { Pop-Location } } else { Write-Ok "Using local files (not a git clone)" } return } New-Item -ItemType Directory -Force -Path $Dir | Out-Null $git = Get-Command git -ErrorAction SilentlyContinue if ($git) { if (Test-Path (Join-Path $Dir ".git")) { Push-Location $Dir try { git fetch --quiet origin $Branch git checkout --quiet $Branch git pull --ff-only --quiet origin $Branch Write-Ok "Pulled latest into $Dir" } finally { Pop-Location } } else { $parent = Split-Path -Parent $Dir New-Item -ItemType Directory -Force -Path $parent | Out-Null if ((Get-ChildItem -Force $Dir -ErrorAction SilentlyContinue | Measure-Object).Count -gt 0) { # Reinstall over a previous broken install directory. Remove-Item -Force -Recurse $Dir New-Item -ItemType Directory -Force -Path $Dir | Out-Null } Remove-Item -Force -Recurse $Dir -ErrorAction SilentlyContinue git clone --branch $Branch --depth 1 $RepoUrl $Dir Write-Ok "Cloned $RepoUrl -> $Dir" } return } Write-Warn "git not found - downloading ZIP from GitHub" $zipUrl = "https://github.com/Dente22/Spokes/archive/refs/heads/$Branch.zip" $tmpZip = Join-Path $env:TEMP "spokes-$Branch.zip" $tmpExtract = Join-Path $env:TEMP "spokes-extract-$Branch" Invoke-WebRequest -Uri $zipUrl -OutFile $tmpZip -UseBasicParsing if (Test-Path $tmpExtract) { Remove-Item -Recurse -Force $tmpExtract } Expand-Archive -Path $tmpZip -DestinationPath $tmpExtract -Force $inner = Get-ChildItem $tmpExtract | Select-Object -First 1 if (-not $inner) { throw "ZIP extract failed" } if (Test-Path $Dir) { Remove-Item -Recurse -Force $Dir } Move-Item $inner.FullName $Dir Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $tmpExtract -ErrorAction SilentlyContinue Write-Ok "Downloaded sources to $Dir" } function Install-Dependencies([string]$Dir) { if ($SkipNpm) { Write-Warn "Skipping npm install (-SkipNpm)" return } Write-Step "npm install" if (-not $script:NpmCmd -or -not (Test-Path $script:NpmCmd)) { throw "npm not found next to Node runtime ($script:NodeExe)" } # Clean a previously half-failed native build. $nm = Join-Path $Dir "node_modules" if (Test-Path $nm) { Write-Ok "Cleaning previous node_modules" Remove-Item -Recurse -Force $nm -ErrorAction SilentlyContinue } Push-Location $Dir try { & $script:NpmCmd install --no-fund --no-audit if ($LASTEXITCODE -ne 0) { throw ("npm install failed (exit $LASTEXITCODE). Spokes needs better-sqlite3. On Windows use Node 20/22 (installer tries this automatically) or install Visual Studio Build Tools with the C++ workload, then re-run the installer.") } $nodeVer = & $script:NodeExe -v Write-Ok "Dependencies installed with $nodeVer" } finally { Pop-Location } } function Get-TsxCli([string]$Dir) { $candidates = @( (Join-Path $Dir "node_modules\tsx\dist\cli.mjs"), (Join-Path $Dir "node_modules\tsx\dist\cli.js") ) foreach ($c in $candidates) { if (Test-Path $c) { return $c } } return $null } function ConvertTo-PlainObject($Value) { if ($null -eq $Value) { return $null } if ($Value -is [System.Collections.IDictionary]) { $map = [ordered]@{} foreach ($key in $Value.Keys) { $map[[string]$key] = ConvertTo-PlainObject $Value[$key] } return $map } if ($Value -is [System.Management.Automation.PSCustomObject]) { $map = [ordered]@{} foreach ($prop in $Value.PSObject.Properties) { $map[$prop.Name] = ConvertTo-PlainObject $prop.Value } return $map } if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { $list = @() foreach ($item in $Value) { $list += ,(ConvertTo-PlainObject $item) } return $list } return $Value } function Merge-McpConfig([string]$Dir) { Write-Step "Registering MCP in Cursor (~/.cursor/mcp.json)" $cursorDir = Join-Path $env:USERPROFILE ".cursor" $mcpPath = Join-Path $cursorDir "mcp.json" New-Item -ItemType Directory -Force -Path $cursorDir | Out-Null # Cursor does not reliably apply "cwd", so every path must be absolute. $dirPosix = ($Dir -replace "\\", "/") $nodePosix = ($script:NodeExe -replace "\\", "/") $entryPoint = "$dirPosix/src/index.ts" $tsxCli = Get-TsxCli $Dir if ($tsxCli) { $entry = [ordered]@{ command = $nodePosix args = @(($tsxCli -replace "\\", "/"), $entryPoint) cwd = $dirPosix env = [ordered]@{ SPOKES_GITHUB_CLIENT_ID = $GithubClientId } } } else { Write-Warn "tsx not found under node_modules - falling back to npx" $entry = [ordered]@{ command = $nodePosix args = @(($script:NpmCmd -replace "\\", "/"), "exec", "--", "tsx", $entryPoint) cwd = $dirPosix env = [ordered]@{ SPOKES_GITHUB_CLIENT_ID = $GithubClientId } } } $servers = [ordered]@{} $extra = [ordered]@{} if (Test-Path $mcpPath) { $raw = Get-Content -Raw -Path $mcpPath if ($raw.Trim().Length -gt 0) { try { $parsed = $raw | ConvertFrom-Json if ($parsed.mcpServers) { foreach ($prop in $parsed.mcpServers.PSObject.Properties) { $servers[$prop.Name] = ConvertTo-PlainObject $prop.Value } } foreach ($prop in $parsed.PSObject.Properties) { if ($prop.Name -ne "mcpServers") { $extra[$prop.Name] = ConvertTo-PlainObject $prop.Value } } Write-Ok "Merging into existing mcp.json (other servers kept)" } catch { $backup = "$mcpPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" Copy-Item $mcpPath $backup Write-Warn "Could not parse mcp.json - backed up to $backup and recreating" $servers = [ordered]@{} $extra = [ordered]@{} } } } else { Write-Ok "Creating new mcp.json" } $servers["spokes"] = $entry $out = [ordered]@{ mcpServers = $servers } foreach ($key in $extra.Keys) { $out[$key] = $extra[$key] } $json = ($out | ConvertTo-Json -Depth 8) + "`n" $utf8 = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($mcpPath, $json, $utf8) Write-Ok "Wrote $mcpPath" Write-Ok "Bundled GitHub OAuth Client ID (Connect GitHub only - no developer setup)" } function Install-Skill([string]$Dir) { Write-Step "Installing Cursor skill" $src = Join-Path $Dir "skill\SKILL.md" if (-not (Test-Path $src)) { Write-Warn "skill/SKILL.md not found - skipped" return } $destDir = Join-Path $env:USERPROFILE ".cursor\skills\spokes" New-Item -ItemType Directory -Force -Path $destDir | Out-Null Copy-Item $src (Join-Path $destDir "SKILL.md") -Force Write-Ok "Skill -> $destDir\SKILL.md" } function Show-Done([string]$Dir) { Write-Host "" Write-Host "Spokes installed." -ForegroundColor Green Write-Host "" Write-Host "Next steps:" Write-Host " 1. Fully restart Cursor (so it reloads MCP)." Write-Host " 2. Settings -> MCP -> confirm 'spokes' is enabled / green." Write-Host " 3. Open http://127.0.0.1:3847 and click Connect GitHub." Write-Host "" Write-Host "Install path: $Dir" Write-Host "Node runtime: $script:NodeExe" Write-Host "Uninstall: $Dir\scripts\uninstall.ps1" Write-Host "" if (-not $NoOpen) { try { Start-Process "http://127.0.0.1:3847" } catch { } } } Write-Host "Spokes installer for Cursor" -ForegroundColor White Ensure-NodeRuntime $dir = Resolve-InstallDir Ensure-AppFiles $dir Install-Dependencies $dir Merge-McpConfig $dir Install-Skill $dir Show-Done $dir