diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a530beb..e2ebb1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,12 +16,23 @@ jobs: - target: bun-darwin-arm64 artifact: polar-darwin-arm64 os: ubuntu-latest + ext: tar.gz + binary: polar - target: bun-darwin-x64 artifact: polar-darwin-x64 os: ubuntu-latest + ext: tar.gz + binary: polar - target: bun-linux-x64 artifact: polar-linux-x64 os: ubuntu-latest + ext: tar.gz + binary: polar + - target: bun-windows-x64 + artifact: polar-windows-x64 + os: ubuntu-latest + ext: zip + binary: polar.exe runs-on: ${{ matrix.os }} @@ -35,15 +46,20 @@ jobs: - run: bun install - name: Build binary - run: bun build ./src/cli.ts --compile --target=${{ matrix.target }} --outfile polar + run: bun build ./src/cli.ts --compile --target=${{ matrix.target }} --outfile ${{ matrix.binary }} + + - name: Package binary (tar.gz) + if: matrix.ext == 'tar.gz' + run: tar -czf ${{ matrix.artifact }}.tar.gz ${{ matrix.binary }} - - name: Package binary - run: tar -czf ${{ matrix.artifact }}.tar.gz polar + - name: Package binary (zip) + if: matrix.ext == 'zip' + run: zip ${{ matrix.artifact }}.zip ${{ matrix.binary }} - uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} - path: ${{ matrix.artifact }}.tar.gz + path: ${{ matrix.artifact }}.${{ matrix.ext }} release: needs: build @@ -55,12 +71,12 @@ jobs: merge-multiple: true - name: Generate checksums - run: sha256sum *.tar.gz > checksums.txt + run: sha256sum polar-* > checksums.txt - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: | - *.tar.gz + polar-* checksums.txt generate_release_notes: true diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..5d66450 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,108 @@ +# Polar CLI installer for Windows +# Usage: powershell -ExecutionPolicy ByPass -c "irm https://raw.githubusercontent.com/polarsource/cli/main/install.ps1 | iex" + +$ErrorActionPreference = "Stop" + +$Repo = "polarsource/cli" +$BinaryName = "polar.exe" +$InstallDir = Join-Path $HOME ".polar\bin" + +function Write-Info { param($Message) Write-Host "==> $Message" -ForegroundColor Green } +function Write-Warn { param($Message) Write-Host "warning: $Message" -ForegroundColor Yellow } +function Write-Err { param($Message) Write-Host "error: $Message" -ForegroundColor Red; exit 1 } + +# Detect architecture +$Arch = $env:PROCESSOR_ARCHITECTURE +if ($Arch -ne "AMD64") { + Write-Err "Unsupported architecture: $Arch. Only x64 (AMD64) is supported." +} + +$Platform = "windows-x64" + +# Fetch latest version +Write-Info "Fetching latest version..." +try { + $Release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -Headers @{ "User-Agent" = "polar-installer" } + $Version = $Release.tag_name +} catch { + Write-Err "Failed to fetch latest version. Check your network connection." +} +if (-not $Version) { + Write-Err "Failed to determine latest version from GitHub API response." +} +Write-Info "Version: $Version" + +# Set up temp directory +$TempDir = Join-Path $env:TEMP "polar-install-$(Get-Random)" +New-Item -ItemType Directory -Force -Path $TempDir | Out-Null + +try { + # Download archive + $Archive = "polar-$Platform.zip" + $ArchiveUrl = "https://github.com/$Repo/releases/download/$Version/$Archive" + $ArchivePath = Join-Path $TempDir $Archive + + Write-Info "Downloading $BinaryName $Version..." + try { + Invoke-WebRequest -Uri $ArchiveUrl -OutFile $ArchivePath -UseBasicParsing + } catch { + Write-Err "Download failed. Check if a release exists for your platform: $Platform" + } + + # Download checksums + $ChecksumsUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" + $ChecksumsPath = Join-Path $TempDir "checksums.txt" + try { + Invoke-WebRequest -Uri $ChecksumsUrl -OutFile $ChecksumsPath -UseBasicParsing + } catch { + Write-Err "Failed to download checksums." + } + + # Verify checksum + Write-Info "Verifying checksum..." + $ActualHash = (Get-FileHash -Path $ArchivePath -Algorithm SHA256).Hash.ToLower() + $ExpectedLine = Get-Content $ChecksumsPath | Where-Object { $_ -match $Archive } + if (-not $ExpectedLine) { + Write-Err "No checksum found for $Archive" + } + $ExpectedHash = ($ExpectedLine -split '\s+')[0].ToLower() + if ($ActualHash -ne $ExpectedHash) { + Write-Err "Checksum mismatch!`n Expected: $ExpectedHash`n Got: $ActualHash" + } + Write-Info "Checksum verified." + + # Extract + Write-Info "Extracting..." + Expand-Archive -LiteralPath $ArchivePath -DestinationPath $TempDir -Force + + # Install + Write-Info "Installing to $InstallDir..." + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + + $SourcePath = Join-Path $TempDir $BinaryName + $DestPath = Join-Path $InstallDir $BinaryName + + try { + Copy-Item -Path $SourcePath -Destination $DestPath -Force + } catch { + Write-Err "Failed to install. If polar is running, close it and try again." + } + + # Update PATH + $CurrentPath = [Environment]::GetEnvironmentVariable("PATH", "User") + if ($CurrentPath -notlike "*$InstallDir*") { + [Environment]::SetEnvironmentVariable("PATH", "$InstallDir;$CurrentPath", "User") + $env:PATH = "$InstallDir;$env:PATH" + } + + Write-Info "Polar CLI $Version installed successfully!" + Write-Host "" + Write-Host " Run 'polar --help' to get started." + Write-Host "" + if ($CurrentPath -notlike "*$InstallDir*") { + Write-Warn "Restart other open terminals for PATH changes to take effect." + } +} finally { + # Cleanup + Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/package.json b/package.json index d6785f4..a1a479d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "build:binary": "bun build ./src/cli.ts --compile --outfile polar", "build:binary:darwin-arm64": "bun build ./src/cli.ts --compile --target=bun-darwin-arm64 --outfile polar", "build:binary:darwin-x64": "bun build ./src/cli.ts --compile --target=bun-darwin-x64 --outfile polar", - "build:binary:linux-x64": "bun build ./src/cli.ts --compile --target=bun-linux-x64 --outfile polar" + "build:binary:linux-x64": "bun build ./src/cli.ts --compile --target=bun-linux-x64 --outfile polar", + "build:binary:windows-x64": "bun build ./src/cli.ts --compile --target=bun-windows-x64 --outfile polar.exe" }, "files": [ "bin", diff --git a/src/cli.ts b/src/cli.ts index 917dbbd..cf698ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ import { Command } from "@effect/cli"; import { BunContext, BunRuntime } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; +import { existsSync, renameSync, unlinkSync } from "fs"; import { listen } from "./commands/listen"; import { login } from "./commands/login"; import { migrate } from "./commands/migrate"; @@ -32,6 +33,17 @@ const services = Layer.mergeAll( BunContext.layer ); +// Clean up stale files from Windows self-update +if (process.platform === "win32") { + // If .new exists, a previous update was interrupted — complete it + const pendingPath = process.execPath + ".new"; + if (existsSync(pendingPath)) { + try { renameSync(pendingPath, process.execPath); } catch {} + } + try { unlinkSync(process.execPath + ".bak"); } catch {} + try { unlinkSync(pendingPath); } catch {} +} + showUpdateNotice(); checkForUpdateInBackground(); diff --git a/src/commands/update.ts b/src/commands/update.ts index 08ee83b..d7bcd55 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,6 +1,7 @@ import { Command } from "@effect/cli"; import { Console, Effect, Schema } from "effect"; import { createHash } from "crypto"; +import { renameSync, unlinkSync } from "fs"; import { chmod, mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; @@ -30,6 +31,9 @@ function detectPlatform(): { os: string; arch: string } { case "linux": os = "linux"; break; + case "win32": + os = "windows"; + break; default: throw new Error(`Unsupported OS: ${platform}`); } @@ -50,6 +54,10 @@ function detectPlatform(): { os: string; arch: string } { throw new Error("Linux arm64 is not yet supported"); } + if (os === "windows" && normalizedArch === "arm64") { + throw new Error("Windows arm64 is not yet supported"); + } + return { os, arch: normalizedArch }; } @@ -65,8 +73,12 @@ const downloadAndUpdate = ( const reset = "\x1b[0m"; const { os, arch } = detectPlatform(); + const binaryPath = process.execPath; const platform = `${os}-${arch}`; - const archiveName = `polar-${platform}.tar.gz`; + const isWindows = process.platform === "win32"; + const archiveExt = isWindows ? "zip" : "tar.gz"; + const binaryName = isWindows ? "polar.exe" : "polar"; + const archiveName = `polar-${platform}.${archiveExt}`; const asset = release.assets.find((a) => a.name === archiveName); if (!asset) { @@ -155,36 +167,76 @@ const downloadAndUpdate = ( yield* Console.log(`${dim}Extracting...${reset}`); - const tar = Bun.spawn(["tar", "-xzf", archivePath, "-C", tempDir], { - stdout: "ignore", - stderr: "pipe", - }); + if (isWindows) { + const ps = Bun.spawn( + [ + "powershell", + "-NoProfile", + "-Command", + `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${tempDir.replace(/'/g, "''")}' -Force`, + ], + { + stdout: "ignore", + stderr: "pipe", + }, + ); - const tarExitCode = yield* Effect.tryPromise({ - try: () => tar.exited, - catch: () => new Error("Failed to extract archive"), - }); + const psExitCode = yield* Effect.tryPromise({ + try: () => ps.exited, + catch: () => new Error("Failed to extract archive"), + }); - if (tarExitCode !== 0) { - const stderr = yield* Effect.tryPromise({ - try: () => new Response(tar.stderr).text(), - catch: () => new Error("Failed to read tar stderr"), + if (psExitCode !== 0) { + const stderr = yield* Effect.tryPromise({ + try: () => new Response(ps.stderr).text(), + catch: () => new Error("Failed to read extraction stderr"), + }); + return yield* Effect.fail( + new Error(`Failed to extract archive: ${stderr}`), + ); + } + } else { + const tar = Bun.spawn(["tar", "-xzf", archivePath, "-C", tempDir], { + stdout: "ignore", + stderr: "pipe", }); - return yield* Effect.fail( - new Error(`Failed to extract archive: ${stderr}`), - ); + + const tarExitCode = yield* Effect.tryPromise({ + try: () => tar.exited, + catch: () => new Error("Failed to extract archive"), + }); + + if (tarExitCode !== 0) { + const stderr = yield* Effect.tryPromise({ + try: () => new Response(tar.stderr).text(), + catch: () => new Error("Failed to read tar stderr"), + }); + return yield* Effect.fail( + new Error(`Failed to extract archive: ${stderr}`), + ); + } } - const binaryPath = process.execPath; - const newBinaryPath = join(tempDir, "polar"); + const newBinaryPath = join(tempDir, binaryName); yield* Console.log(`${dim}Replacing binary...${reset}`); yield* Effect.tryPromise({ try: async () => { const newBinary = await Bun.file(newBinaryPath).arrayBuffer(); - await Bun.write(binaryPath, newBinary); - await chmod(binaryPath, 0o755); + if (isWindows) { + const bakPath = binaryPath + ".bak"; + const pendingPath = binaryPath + ".new"; + await Bun.write(pendingPath, newBinary); + renameSync(binaryPath, bakPath); + renameSync(pendingPath, binaryPath); + try { + unlinkSync(bakPath); + } catch {} + } else { + await Bun.write(binaryPath, newBinary); + await chmod(binaryPath, 0o755); + } }, catch: (e) => new Error( diff --git a/src/services/oauth.ts b/src/services/oauth.ts index d29f6bb..987abfd 100644 --- a/src/services/oauth.ts +++ b/src/services/oauth.ts @@ -87,7 +87,7 @@ const captureAccessTokenFromHTTPServer = (server: PolarEnvironment) => } }); - httpServer?.listen(3333, () => { + httpServer?.listen(3333, "127.0.0.1", () => { open(authorizationUrl); }); });