diff --git a/.github/workflows/windows-smoke-test.yml b/.github/workflows/windows-smoke-test.yml index 904cf245..9ee41499 100644 --- a/.github/workflows/windows-smoke-test.yml +++ b/.github/workflows/windows-smoke-test.yml @@ -1,4 +1,4 @@ -name: Windows Smoke Test +name: Smoke Tests on: pull_request: @@ -15,10 +15,20 @@ concurrency: jobs: smoke-test: - name: Windows Smoke Test - runs-on: windows-latest + name: Smoke Test (${{ matrix.os }}) + runs-on: ${{ matrix.runner }} timeout-minutes: 15 if: github.event.pull_request.draft == false + strategy: + fail-fast: false + matrix: + include: + - os: Windows + runner: windows-latest + binary_name: archgate.exe + - os: Linux + runner: ubuntu-latest + binary_name: archgate steps: - name: Checkout code uses: actions/checkout@v4 @@ -33,21 +43,26 @@ jobs: run: bun install --frozen-lockfile - name: Lint + if: matrix.os == 'Windows' run: bun run lint - name: Typecheck + if: matrix.os == 'Windows' run: bun run typecheck - name: Format check + if: matrix.os == 'Windows' run: bun run format:check - name: Tests + if: matrix.os == 'Windows' run: bun test --timeout 60000 - - name: Build Windows binary + - name: Build binary run: bun build src/cli.ts --compile --bytecode --outfile dist/archgate-smoke-test - - name: Smoke test binary + - name: Smoke test binary (Windows) + if: matrix.os == 'Windows' shell: pwsh run: | $binary = "dist/archgate-smoke-test.exe" @@ -61,3 +76,89 @@ jobs: Write-Error "Binary exited with code $LASTEXITCODE" exit 1 } + + - name: Smoke test binary (Linux) + if: matrix.os == 'Linux' + run: | + binary="dist/archgate-smoke-test" + if [ ! -f "$binary" ]; then + echo "::error::Binary not found at $binary" + exit 1 + fi + chmod +x "$binary" + output=$("$binary" --version 2>&1) + echo "archgate version: $output" + + - name: Build old-version binary for upgrade test (Windows) + if: matrix.os == 'Windows' + shell: pwsh + run: | + $pkg = Get-Content package.json -Raw | ConvertFrom-Json + $originalVersion = $pkg.version + $pkg.version = "0.0.1" + $pkg | ConvertTo-Json -Depth 10 | Set-Content package.json + bun build src/cli.ts --compile --bytecode --outfile dist/archgate-upgrade-test + $pkg.version = $originalVersion + $pkg | ConvertTo-Json -Depth 10 | Set-Content package.json + + - name: Build old-version binary for upgrade test (Linux) + if: matrix.os == 'Linux' + run: | + original_version=$(jq -r .version package.json) + jq '.version = "0.0.1"' package.json > package.json.tmp && mv package.json.tmp package.json + bun build src/cli.ts --compile --bytecode --outfile dist/archgate-upgrade-test + jq --arg v "$original_version" '.version = $v' package.json > package.json.tmp && mv package.json.tmp package.json + + - name: Smoke test upgrade (Windows) + if: matrix.os == 'Windows' + shell: pwsh + run: | + $binDir = "$HOME\.archgate\bin" + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + Copy-Item "dist/archgate-upgrade-test.exe" "$binDir\archgate.exe" -Force + + $before = & "$binDir\archgate.exe" --version 2>&1 + Write-Host "Before upgrade: $before" + if ($before -ne "0.0.1") { + Write-Error "Expected version 0.0.1, got $before" + exit 1 + } + + & "$binDir\archgate.exe" upgrade 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "Upgrade failed with exit code $LASTEXITCODE" + exit 1 + } + + $after = & "$binDir\archgate.exe" --version 2>&1 + Write-Host "After upgrade: $after" + if ($after -eq "0.0.1") { + Write-Error "Binary was not replaced — still reports 0.0.1" + exit 1 + } + Write-Host "Upgrade smoke test passed: $before -> $after" + + - name: Smoke test upgrade (Linux) + if: matrix.os == 'Linux' + run: | + bin_dir="$HOME/.archgate/bin" + mkdir -p "$bin_dir" + cp dist/archgate-upgrade-test "$bin_dir/archgate" + chmod +x "$bin_dir/archgate" + + before=$("$bin_dir/archgate" --version 2>&1) + echo "Before upgrade: $before" + if [ "$before" != "0.0.1" ]; then + echo "::error::Expected version 0.0.1, got $before" + exit 1 + fi + + "$bin_dir/archgate" upgrade 2>&1 + + after=$("$bin_dir/archgate" --version 2>&1) + echo "After upgrade: $after" + if [ "$after" = "0.0.1" ]; then + echo "::error::Binary was not replaced — still reports 0.0.1" + exit 1 + fi + echo "Upgrade smoke test passed: $before -> $after" diff --git a/src/cli.ts b/src/cli.ts index bfc866c9..67338adf 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -49,7 +49,10 @@ async function main() { registerUpgradeCommand(program); registerCleanCommand(program); - const updateCheckPromise = checkForUpdatesIfNeeded(packageJson.version); + const isUpgrade = process.argv.includes("upgrade"); + const updateCheckPromise = isUpgrade + ? Promise.resolve(null) + : checkForUpdatesIfNeeded(packageJson.version); await program.parseAsync(process.argv); const notice = await updateCheckPromise; if (notice) console.log(notice); diff --git a/src/commands/clean.ts b/src/commands/clean.ts index 7299d2eb..004864be 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -1,11 +1,20 @@ -import { existsSync } from "node:fs"; -import { rmSync } from "node:fs"; +import { existsSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; import type { Command } from "@commander-js/extra-typings"; import { logError } from "../helpers/log"; import { internalPath } from "../helpers/paths"; +/** + * Check whether the running binary lives under ~/.archgate/bin/. + * When true, the bin/ directory must be preserved during clean. + */ +function shouldPreserveBinDir(): boolean { + const binDir = internalPath("bin"); + return process.execPath.startsWith(binDir); +} + export function registerCleanCommand(program: Command) { program .command("clean") @@ -18,9 +27,23 @@ export function registerCleanCommand(program: Command) { return; } + const preserveBin = shouldPreserveBinDir(); + try { - rmSync(destinationPath, { recursive: true, force: true }); - console.log(`${destinationPath} cleaned up`); + if (preserveBin) { + // Remove everything except bin/ to avoid deleting the running binary + for (const entry of readdirSync(destinationPath)) { + if (entry === "bin") continue; + rmSync(join(destinationPath, entry), { + recursive: true, + force: true, + }); + } + console.log(`${destinationPath} cleaned up (bin/ preserved)`); + } else { + rmSync(destinationPath, { recursive: true, force: true }); + console.log(`${destinationPath} cleaned up`); + } } catch (error) { logError( `Failed to clean ${destinationPath}.`, diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index e3a9f75f..96046efb 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -1,11 +1,36 @@ import type { Command } from "@commander-js/extra-typings"; import { semver } from "bun"; +import { + downloadReleaseBinary, + fetchLatestGitHubVersion, + getArtifactInfo, + getManualInstallHint, + replaceBinary, +} from "../helpers/binary-upgrade"; import { logError } from "../helpers/log"; -import { resolveCommand } from "../helpers/platform"; +import { internalPath } from "../helpers/paths"; +import { getPlatformInfo, resolveCommand } from "../helpers/platform"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- const NPM_REGISTRY = "https://registry.npmjs.org/archgate/latest"; +// --------------------------------------------------------------------------- +// Install method detection +// --------------------------------------------------------------------------- + +type InstallMethod = + | { type: "binary"; binaryPath: string } + | { + type: "package-manager"; + cmd: string; + args: string[]; + manualHint: string; + }; + interface PackageManager { name: string; globalBinCmd: string[]; @@ -35,10 +60,11 @@ const PACKAGE_MANAGERS: PackageManager[] = [ }, ]; -/** - * Get the global bin directory for a package manager. - * Returns null if the command is not available or fails. - */ +function isBinaryInstall(): boolean { + const binDir = internalPath("bin"); + return process.execPath.startsWith(binDir); +} + async function getGlobalBinDir(cmd: string[]): Promise { try { const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); @@ -51,19 +77,13 @@ async function getGlobalBinDir(cmd: string[]): Promise { } } -/** - * Detect which package manager installed archgate by checking whether - * the running binary lives under each manager's global bin directory. - * Resolves all candidates in parallel. Falls back to npm if none match. - */ -async function detectPackageManager(): Promise<{ - cmd: string; - args: string[]; - manualHint: string; -}> { +async function detectInstallMethod(): Promise { + if (isBinaryInstall()) { + return { type: "binary", binaryPath: process.execPath }; + } + const binaryPath = process.execPath; - // Resolve all package managers in parallel const candidates = await Promise.all( PACKAGE_MANAGERS.map(async (pm) => { const resolved = await resolveCommand(pm.name); @@ -74,29 +94,162 @@ async function detectPackageManager(): Promise<{ }) ); - // Find which PM's global bin dir contains the running binary const match = candidates.find( (c) => c?.binDir && binaryPath.startsWith(c.binDir) ); if (match) { return { + type: "package-manager", cmd: match.resolved, args: match.pm.upgradeArgs, manualHint: `${match.pm.name} ${match.pm.upgradeArgs.join(" ")}`, }; } - // Default to npm const npmCandidate = candidates.find((c) => c?.pm.name === "npm"); const npm = PACKAGE_MANAGERS.find((pm) => pm.name === "npm")!; return { + type: "package-manager", cmd: npmCandidate?.resolved ?? "npm", args: npm.upgradeArgs, manualHint: `npm ${npm.upgradeArgs.join(" ")}`, }; } +// --------------------------------------------------------------------------- +// Version fetching (npm) +// --------------------------------------------------------------------------- + +async function fetchLatestNpmVersion(): Promise { + const response = await fetch(NPM_REGISTRY, { + headers: { "User-Agent": "archgate-cli" }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + logError( + "Failed to fetch release info from npm registry.", + `HTTP ${response.status}. Check your network connection.` + ); + return null; + } + + const data = (await response.json()) as { version?: string }; + return data.version ?? null; +} + +// --------------------------------------------------------------------------- +// Upgrade flows +// --------------------------------------------------------------------------- + +async function upgradeBinaryInstall(currentVersion: string): Promise { + const artifact = getArtifactInfo(); + if (!artifact) { + logError( + `Unsupported platform: ${getPlatformInfo().runtime}/${process.arch}`, + "archgate supports darwin/arm64, linux/x64, and win32/x64." + ); + process.exit(2); + } + + const tag = await fetchLatestGitHubVersion(); + if (!tag) { + logError( + "Failed to fetch release info from GitHub.", + "Check your network connection." + ); + process.exit(1); + } + + const latestVersion = tag.replace(/^v/, ""); + const order = semver.order(currentVersion, latestVersion); + + if (order === null) { + logError( + `Could not compare versions: ${currentVersion} vs ${latestVersion}` + ); + process.exit(2); + } + + if (order >= 0) { + console.log(`Archgate is already up-to-date (${currentVersion}).`); + process.exit(0); + } + + console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); + + const hint = getManualInstallHint(); + let newBinaryPath: string; + try { + newBinaryPath = await downloadReleaseBinary(tag, artifact); + } catch (err) { + logError( + "Failed to download the latest release.", + `${err instanceof Error ? err.message : String(err)}\nTry running \`${hint}\` manually.` + ); + process.exit(1); + } + + try { + replaceBinary(process.execPath, newBinaryPath); + } catch (err) { + logError( + "Failed to replace the binary.", + `${err instanceof Error ? err.message : String(err)}\nTry running \`${hint}\` manually.` + ); + process.exit(1); + } + + console.log(`Archgate upgraded to ${latestVersion} successfully.`); +} + +async function upgradePackageManager( + currentVersion: string, + method: Extract +): Promise { + const latestVersion = await fetchLatestNpmVersion(); + if (!latestVersion) { + process.exit(1); + } + + const order = semver.order(currentVersion, latestVersion); + + if (order === null) { + logError( + `Could not compare versions: ${currentVersion} vs ${latestVersion}` + ); + process.exit(2); + } + + if (order >= 0) { + console.log(`Archgate is already up-to-date (${currentVersion}).`); + process.exit(0); + } + + console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); + + const proc = Bun.spawn([method.cmd, ...method.args], { + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + logError( + "Failed to install the latest version.", + `Try running \`${method.manualHint}\` manually.` + ); + process.exit(1); + } + + console.log(`Archgate upgraded to ${latestVersion} successfully.`); +} + +// --------------------------------------------------------------------------- +// Command registration +// --------------------------------------------------------------------------- + export function registerUpgradeCommand(program: Command) { program .command("upgrade") @@ -104,69 +257,24 @@ export function registerUpgradeCommand(program: Command) { .action(async () => { console.log("Checking for latest Archgate release..."); - let latestVersion: string; - try { - const response = await fetch(NPM_REGISTRY, { - headers: { "User-Agent": "archgate-cli" }, - signal: AbortSignal.timeout(15000), - }); - - if (!response.ok) { - logError( - "Failed to fetch release info from npm registry.", - `HTTP ${response.status}. Check your network connection.` - ); - process.exit(1); - } - - const data = (await response.json()) as { version?: string }; - if (!data.version) { - logError("Could not parse version from npm registry response."); - process.exit(2); - } - latestVersion = data.version; - } catch { - logError( - "Failed to reach npm registry.", - "Check your network connection and try again." - ); - process.exit(1); - } - const packageJson = await import("../../package.json"); const currentVersion = packageJson.default.version; - const order = semver.order(currentVersion, latestVersion); - - if (order === null) { - logError( - `Could not compare versions: ${currentVersion} vs ${latestVersion}` - ); - process.exit(2); - } - if (order >= 0) { - console.log(`Archgate is already up-to-date (${currentVersion}).`); - process.exit(0); - } - - console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); - - const { cmd, args, manualHint } = await detectPackageManager(); - - const proc = Bun.spawn([cmd, ...args], { - stdout: "inherit", - stderr: "inherit", - }); - const exitCode = await proc.exited; + const method = await detectInstallMethod(); - if (exitCode !== 0) { - logError( - "Failed to install the latest version.", - `Try running \`${manualHint}\` manually.` - ); - process.exit(1); + if (method.type === "binary") { + await upgradeBinaryInstall(currentVersion); + } else { + await upgradePackageManager(currentVersion, method); } - - console.log(`Archgate upgraded to ${latestVersion} successfully.`); }); } + +// --------------------------------------------------------------------------- +// Exports for testing +// --------------------------------------------------------------------------- + +export { + isBinaryInstall as _isBinaryInstall, + detectInstallMethod as _detectInstallMethod, +}; diff --git a/src/helpers/binary-upgrade.ts b/src/helpers/binary-upgrade.ts new file mode 100644 index 00000000..73504567 --- /dev/null +++ b/src/helpers/binary-upgrade.ts @@ -0,0 +1,185 @@ +import { chmodSync, mkdtempSync, renameSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { isWindows } from "./platform"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const GITHUB_REPO = "archgate/cli"; + +// --------------------------------------------------------------------------- +// Artifact resolution +// --------------------------------------------------------------------------- + +export interface ArtifactInfo { + /** e.g. "archgate-darwin-arm64" */ + name: string; + /** e.g. ".tar.gz" or ".zip" */ + ext: string; + /** e.g. "archgate" or "archgate.exe" */ + binaryName: string; +} + +export function getArtifactInfo(): ArtifactInfo | null { + const { platform, arch } = process; + + if (platform === "darwin" && arch === "arm64") { + return { + name: "archgate-darwin-arm64", + ext: ".tar.gz", + binaryName: "archgate", + }; + } + if (platform === "linux" && arch === "x64") { + return { + name: "archgate-linux-x64", + ext: ".tar.gz", + binaryName: "archgate", + }; + } + if (platform === "win32" && arch === "x64") { + return { + name: "archgate-win32-x64", + ext: ".zip", + binaryName: "archgate.exe", + }; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Version fetching +// --------------------------------------------------------------------------- + +interface GitHubRelease { + tag_name?: string; +} + +const GITHUB_RELEASES_API = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`; + +/** + * Fetch the latest version tag from GitHub Releases. + * Returns the tag (e.g. "v0.13.1") or null on failure. + */ +export async function fetchLatestGitHubVersion(): Promise { + const response = await fetch(GITHUB_RELEASES_API, { + headers: { "User-Agent": "archgate-cli" }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) return null; + + const data = (await response.json()) as GitHubRelease; + return data.tag_name ?? null; +} + +// --------------------------------------------------------------------------- +// Download and extract +// --------------------------------------------------------------------------- + +/** + * Download and extract the release binary to a temp directory. + * Returns the path to the extracted binary. + */ +export async function downloadReleaseBinary( + tag: string, + artifact: ArtifactInfo +): Promise { + const archiveUrl = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/${artifact.name}${artifact.ext}`; + + const response = await fetch(archiveUrl, { + headers: { "User-Agent": "archgate-cli" }, + signal: AbortSignal.timeout(60000), + }); + + if (!response.ok) { + throw new Error(`Download failed (HTTP ${response.status})`); + } + + const buffer = await response.arrayBuffer(); + const tmpDir = mkdtempSync(join(tmpdir(), "archgate-upgrade-")); + const archivePath = join(tmpDir, `archgate${artifact.ext}`); + + await Bun.write(archivePath, buffer); + + if (artifact.ext === ".tar.gz") { + const proc = Bun.spawn(["tar", "-xzf", archivePath, "-C", tmpDir], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`Failed to extract archive (tar exit code ${exitCode})`); + } + } else { + const proc = Bun.spawn( + [ + "powershell", + "-NoProfile", + "-Command", + `Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}' -Force`, + ], + { stdout: "pipe", stderr: "pipe" } + ); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error( + `Failed to extract archive (PowerShell exit code ${exitCode})` + ); + } + } + + return join(tmpDir, artifact.binaryName); +} + +// --------------------------------------------------------------------------- +// Binary replacement +// --------------------------------------------------------------------------- + +/** + * Replace the running binary with the new one. + * + * Unix: directly renames the new binary over the old one (OS handles inode unlinking). + * Windows: renames the running exe to .old (allowed by the OS), moves the new one + * into place, and spawns a detached cleanup process for the old file. + */ +export function replaceBinary( + currentPath: string, + newBinaryPath: string +): void { + if (isWindows()) { + const oldPath = currentPath + ".old"; + + // Clean up leftover .old file from a previous upgrade + try { + unlinkSync(oldPath); + } catch { + // Not present — fine + } + + renameSync(currentPath, oldPath); + renameSync(newBinaryPath, currentPath); + + // Spawn detached cleanup — waits for this process to exit, then deletes the old file + Bun.spawn(["cmd", "/c", `ping -n 2 127.0.0.1 >nul & del "${oldPath}"`], { + stdout: "ignore", + stderr: "ignore", + }); + } else { + renameSync(newBinaryPath, currentPath); + chmodSync(currentPath, 0o755); + } +} + +/** + * Returns the manual install hint for the current platform. + */ +export function getManualInstallHint(): string { + return isWindows() + ? "irm https://raw.githubusercontent.com/archgate/cli/main/install.ps1 | iex" + : "curl -fsSL https://raw.githubusercontent.com/archgate/cli/main/install.sh | sh"; +} diff --git a/tests/helpers/binary-upgrade.test.ts b/tests/helpers/binary-upgrade.test.ts new file mode 100644 index 00000000..88bf2948 --- /dev/null +++ b/tests/helpers/binary-upgrade.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; + +import { + getArtifactInfo, + getManualInstallHint, +} from "../../src/helpers/binary-upgrade"; + +describe("getArtifactInfo", () => { + test("returns artifact info for the current platform", () => { + const info = getArtifactInfo(); + + // Should return non-null for any supported CI platform + if (info === null) return; + + expect(info.name).toMatch(/^archgate-(darwin-arm64|linux-x64|win32-x64)$/); + expect(info.ext).toMatch(/^\.(tar\.gz|zip)$/); + expect(info.binaryName).toMatch(/^archgate(\.exe)?$/); + }); + + test("returns .zip extension for win32", () => { + const info = getArtifactInfo(); + if (process.platform !== "win32") return; + expect(info).not.toBeNull(); + expect(info!.ext).toBe(".zip"); + expect(info!.binaryName).toBe("archgate.exe"); + expect(info!.name).toBe("archgate-win32-x64"); + }); + + test("returns .tar.gz extension for non-win32", () => { + const info = getArtifactInfo(); + if (process.platform === "win32") return; + if (info === null) return; + expect(info.ext).toBe(".tar.gz"); + expect(info.binaryName).toBe("archgate"); + }); +}); + +describe("getManualInstallHint", () => { + test("returns platform-appropriate install command", () => { + const hint = getManualInstallHint(); + + if (process.platform === "win32") { + expect(hint).toContain("install.ps1"); + expect(hint).toContain("irm"); + } else { + expect(hint).toContain("install.sh"); + expect(hint).toContain("curl"); + } + }); +});