From 0d5289d9c718f472eef1f444a8bf52d17ab8435b Mon Sep 17 00:00:00 2001 From: josegironn <30703536+josegironn@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:33:34 -0700 Subject: [PATCH] feat: add Windows support for installation and usage Adds native Windows support to the CLI while preserving the existing Linux/macOS experience unchanged. Build & distribution: - GoReleaser builds windows/amd64 and windows/arm64 with .zip archives - Release workflow uploads install.ps1 to S3 alongside install.sh Installation: - install.ps1: PowerShell installer with SHA256 checksum and version verification - install.sh: hardened to fail loudly on version mismatch (parity with .ps1) - cmd/install.go: installWindows branch registers completion in $PROFILE and updates User PATH via [Environment]::SetEnvironmentVariable Updater (cmd/update.go): - Go-native download path for Windows and as fallback when bash is missing - SHA256 checksum verification against checksums.txt - Pre-promotion health check (runs new binary with --version) - Safe Windows binary swap with rollback (.new -> .old -> rename chain) Plugin integration (cross-platform via Go commands): - New hidden command: major mcp get-headers (replaces bash headersHelper) - New hidden command: major mcp check-readonly-hook (replaces bash PreToolUse script) - .mcp.json and auto-approve.json now use the Go commands; work identically on Windows, macOS, and Linux - Kept .sh scripts for backwards compat; added .ps1 equivalents for direct use Dev tooling: - Makefile: dev-install-windows target for PowerShell workflow - cmd/completion.go: added PowerShell completion documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 3 +- .goreleaser.yaml | 5 +- Makefile | 10 +- cmd/completion.go | 7 + cmd/install.go | 131 ++++++++- cmd/mcp/check_readonly.go | 30 ++ cmd/mcp/get_headers.go | 28 ++ cmd/mcp/mcp.go | 2 + cmd/update.go | 358 ++++++++++++++++++++++- install.ps1 | 121 ++++++++ install.sh | 11 +- plugins/major/.mcp.json | 4 +- plugins/major/hooks/auto-approve.json | 2 +- plugins/major/scripts/check-readonly.ps1 | 14 + plugins/major/scripts/get-headers.ps1 | 26 ++ 15 files changed, 724 insertions(+), 28 deletions(-) create mode 100644 cmd/mcp/get_headers.go create mode 100644 install.ps1 create mode 100644 plugins/major/scripts/check-readonly.ps1 create mode 100644 plugins/major/scripts/get-headers.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43baa0b..67edec6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,5 +46,6 @@ jobs: # Upload latest version pointer aws s3 cp latest-version s3://major-cli-releases/latest-version --acl public-read --content-type "text/plain" - # Upload install script + # Upload install scripts aws s3 cp install.sh s3://major-cli-releases/install.sh --acl public-read --content-type "text/plain" + aws s3 cp install.ps1 s3://major-cli-releases/install.ps1 --acl public-read --content-type "text/plain" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index fd13ca1..7718e69 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,11 +15,14 @@ builds: - -s -w - -X github.com/major-technology/cli/cmd.Version={{.Version}} - -X github.com/major-technology/cli/cmd.configFile=configs/prod.json - goos: [darwin, linux] + goos: [darwin, linux, windows] goarch: [amd64, arm64] archives: - formats: [tar.gz] # <- v2 syntax + format_overrides: + - goos: windows + formats: [zip] files: - LICENSE - README.md diff --git a/Makefile b/Makefile index f65795e..77f5879 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: rerelease release dev-install dev-restore +.PHONY: rerelease release dev-install dev-restore dev-install-windows # Build and install locally for development dev-install: @@ -17,6 +17,14 @@ dev-restore: @cp ~/.major/bin/major.backup ~/.major/bin/major @echo "Done!" +# Build and install locally for development on Windows (run from PowerShell) +dev-install-windows: + @echo "Building CLI with prod config..." + go build -ldflags "-X 'github.com/major-technology/cli/cmd.configFile=configs/prod.json'" -o major.exe . + @echo "Installing development build..." + @powershell -Command "New-Item -ItemType Directory -Path '$$env:USERPROFILE\.major\bin' -Force | Out-Null; Copy-Item major.exe '$$env:USERPROFILE\.major\bin\major.exe' -Force; Remove-Item major.exe" + @echo "Done! Run 'major --version' to verify." + rerelease: @echo "Releasing version v$(VERSION)..." git tag -d v$(VERSION) diff --git a/cmd/completion.go b/cmd/completion.go index c265201..0ee4f39 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -39,6 +39,13 @@ Fish: # To load completions for each session, execute once: $ major completion fish > ~/.config/fish/completions/major.fish + +PowerShell: + + PS> major completion powershell | Out-String | Invoke-Expression + + # To load completions for each session, add to your profile: + PS> major completion powershell >> $PROFILE `, DisableFlagsInUseLine: true, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, diff --git a/cmd/install.go b/cmd/install.go index 0cd23fb..e11adfb 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -64,6 +64,22 @@ func runInstall(cmd *cobra.Command) error { return errors.WrapError("failed to get user home dir", err) } + // Create completions directory + completionsDir := filepath.Join(home, ".major", "completions") + if err := os.MkdirAll(completionsDir, 0755); err != nil { + return errors.WrapError("failed to create completions directory", err) + } + + cmd.Println(stepStyle.Render("+ Generating shell completions...")) + + if runtime.GOOS == "windows" { + return installWindows(cmd, binDir, completionsDir, successStyle) + } + + return installUnix(cmd, binDir, home, completionsDir, successStyle) +} + +func installUnix(cmd *cobra.Command, binDir, home, completionsDir string, successStyle lipgloss.Style) error { shell := os.Getenv("SHELL") var configFile string var shellType string @@ -88,15 +104,6 @@ func runInstall(cmd *cobra.Command) error { return nil } - // Create completions directory - completionsDir := filepath.Join(home, ".major", "completions") - if err := os.MkdirAll(completionsDir, 0755); err != nil { - return errors.WrapError("failed to create completions directory", err) - } - - // Generate completion script - cmd.Println(stepStyle.Render("▸ Generating shell completions...")) - var completionEntry string switch shellType { case "zsh": @@ -180,3 +187,109 @@ source "%s" return nil } + +func installWindows(cmd *cobra.Command, binDir, completionsDir string, successStyle lipgloss.Style) error { + // Generate PowerShell completion script + completionFile := filepath.Join(completionsDir, "major.ps1") + f, err := os.Create(completionFile) + if err != nil { + return errors.WrapError("failed to create powershell completion file", err) + } + defer f.Close() + + if err := cmd.Root().GenPowerShellCompletionWithDesc(f); err != nil { + return errors.WrapError("failed to generate powershell completion", err) + } + + // Add binDir to User PATH if not already present + if err := addToWindowsPath(binDir); err != nil { + cmd.Println(fmt.Sprintf("Could not update PATH automatically: %v", err)) + cmd.Println(fmt.Sprintf("Please add %s to your PATH manually.", binDir)) + } + + // Set up PowerShell profile to source completion + if err := setupPowerShellProfile(cmd, completionFile, successStyle); err != nil { + // Non-fatal -- PATH is the important part + cmd.Println(fmt.Sprintf("Could not set up PowerShell completions: %v", err)) + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#00FF00")). + Padding(1, 2). + MarginTop(1) + + msg := fmt.Sprintf("%s\n\nPlease restart your terminal to start using 'major'.", + successStyle.Render("Major CLI installed successfully!")) + + cmd.Println(boxStyle.Render(msg)) + + return nil +} + +func addToWindowsPath(binDir string) error { + // Use PowerShell's [Environment] API -- locale-independent and safe. + out, err := exec.Command("powershell", "-NoProfile", "-Command", + `[Environment]::GetEnvironmentVariable('Path','User')`).Output() + if err != nil { + // PowerShell not available; set PATH directly + return exec.Command("powershell", "-NoProfile", "-Command", + fmt.Sprintf(`[Environment]::SetEnvironmentVariable('Path','%s','User')`, binDir)).Run() + } + + currentPath := strings.TrimSpace(string(out)) + + // Check if already in PATH (case-insensitive on Windows) + for _, p := range strings.Split(currentPath, ";") { + if strings.EqualFold(strings.TrimSpace(p), binDir) { + return nil // already there + } + } + + newPath := currentPath + ";" + binDir + + // Guard against PATH exceeding Windows limit + if len(newPath) > 32760 { + return fmt.Errorf("PATH would exceed Windows limit (%d chars)", len(newPath)) + } + + return exec.Command("powershell", "-NoProfile", "-Command", + fmt.Sprintf(`[Environment]::SetEnvironmentVariable('Path','%s','User')`, newPath)).Run() +} + +func setupPowerShellProfile(cmd *cobra.Command, completionFile string, successStyle lipgloss.Style) error { + // Get PowerShell profile path + out, err := exec.Command("powershell", "-NoProfile", "-Command", "$PROFILE").Output() + if err != nil { + return fmt.Errorf("powershell not found") + } + + profilePath := strings.TrimSpace(string(out)) + if profilePath == "" { + return fmt.Errorf("empty profile path") + } + + marker := "# Major CLI" + completionEntry := fmt.Sprintf("\n%s\n. '%s'\n", marker, completionFile) + + // Check if already configured + content, err := os.ReadFile(profilePath) + if err == nil && strings.Contains(string(content), marker) { + cmd.Println(successStyle.Render("PowerShell completions already configured!")) + return nil + } + + // Ensure profile directory exists + if err := os.MkdirAll(filepath.Dir(profilePath), 0755); err != nil { + return err + } + + f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + _, err = f.WriteString(completionEntry) + return err +} diff --git a/cmd/mcp/check_readonly.go b/cmd/mcp/check_readonly.go index e0d597f..207e7de 100644 --- a/cmd/mcp/check_readonly.go +++ b/cmd/mcp/check_readonly.go @@ -3,9 +3,11 @@ package mcp import ( "encoding/json" "fmt" + "io" "net/http" "os" "path/filepath" + "strings" "time" mjrToken "github.com/major-technology/cli/clients/token" @@ -23,6 +25,34 @@ var checkReadonlyCmd = &cobra.Command{ }, } +var checkReadonlyHookCmd = &cobra.Command{ + Use: "check-readonly-hook", + Short: "Hook entry point: reads PreToolUse JSON from stdin", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + input, err := io.ReadAll(os.Stdin) + if err != nil || len(input) == 0 { + return nil + } + + var payload struct { + ToolName string `json:"tool_name"` + } + if err := json.Unmarshal(input, &payload); err != nil || payload.ToolName == "" { + return nil + } + + // Strip MCP server prefix + actualTool := payload.ToolName + const prefix = "mcp__plugin_major_major-resources__" + if strings.HasPrefix(actualTool, prefix) { + actualTool = actualTool[len(prefix):] + } + + return runCheckReadonly(actualTool) + }, +} + type toolMetadataItem struct { Name string `json:"name"` Description string `json:"description"` diff --git a/cmd/mcp/get_headers.go b/cmd/mcp/get_headers.go new file mode 100644 index 0000000..dfc6600 --- /dev/null +++ b/cmd/mcp/get_headers.go @@ -0,0 +1,28 @@ +package mcp + +import ( + "fmt" + + mjrToken "github.com/major-technology/cli/clients/token" + "github.com/spf13/cobra" +) + +var getHeadersCmd = &cobra.Command{ + Use: "get-headers", + Short: "Output MCP authentication headers as JSON", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + token, err := mjrToken.GetToken() + if err != nil { + return err + } + + orgID, _, err := mjrToken.GetDefaultOrg() + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), `{"Authorization": "Bearer %s", "x-major-org-id": "%s"}`, token, orgID) + return nil + }, +} diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go index 2c47720..3157842 100644 --- a/cmd/mcp/mcp.go +++ b/cmd/mcp/mcp.go @@ -17,4 +17,6 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(checkReadonlyCmd) + Cmd.AddCommand(checkReadonlyHookCmd) + Cmd.AddCommand(getHeadersCmd) } diff --git a/cmd/update.go b/cmd/update.go index 358ff6c..5b0c34e 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -1,9 +1,16 @@ package cmd import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "crypto/sha256" "fmt" + "io" + "net/http" "os" "os/exec" + "path/filepath" "runtime" "strings" @@ -56,11 +63,9 @@ func runUpdate(cmd *cobra.Command) error { } func detectInstallMethod() string { - // Check if installed via brew + // Check if installed via brew (macOS/Linux only) if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { - // Check if brew is available if _, err := exec.LookPath("brew"); err == nil { - // Check if major is installed via brew brewListCmd := exec.Command("brew", "list", "major") if err := brewListCmd.Run(); err == nil { return "brew" @@ -106,20 +111,349 @@ func updateViaBrew(cmd *cobra.Command, stepStyle, successStyle lipgloss.Style) e func updateViaDirect(cmd *cobra.Command, stepStyle, successStyle lipgloss.Style) error { cmd.Println(stepStyle.Render("▸ Downloading latest version...")) - // Use the install script - installScriptURL := "https://raw.githubusercontent.com/major-technology/cli/main/install.sh" + // On Unix with bash available, use the install script for backwards compat + if runtime.GOOS != "windows" { + if _, err := exec.LookPath("bash"); err == nil { + installScriptURL := "https://raw.githubusercontent.com/major-technology/cli/main/install.sh" + curlCmd := exec.Command("bash", "-c", fmt.Sprintf("curl -fsSL %s | bash", installScriptURL)) + curlCmd.Stdout = os.Stdout + curlCmd.Stderr = os.Stderr + curlCmd.Stdin = os.Stdin - // Download and execute the install script - curlCmd := exec.Command("bash", "-c", fmt.Sprintf("curl -fsSL %s | bash", installScriptURL)) - curlCmd.Stdout = os.Stdout - curlCmd.Stderr = os.Stderr - curlCmd.Stdin = os.Stdin // Allow password prompt for sudo + if err := curlCmd.Run(); err != nil { + return errors.WrapError("failed to download and install update", err) + } + + cmd.Println() + cmd.Println(successStyle.Render("✓ Successfully updated Major CLI!")) + return nil + } + } - if err := curlCmd.Run(); err != nil { - return errors.WrapError("failed to download and install update", err) + // Go-native update: download binary directly from S3. + // Used on Windows and as a fallback on Unix without bash. + if err := updateViaDirectDownload(cmd, stepStyle); err != nil { + return err } cmd.Println() cmd.Println(successStyle.Render("✓ Successfully updated Major CLI!")) return nil } + +func updateViaDirectDownload(cmd *cobra.Command, stepStyle lipgloss.Style) error { + s3Bucket := "https://major-cli-releases.s3.us-west-1.amazonaws.com" + + // Get latest version + resp, err := http.Get(s3Bucket + "/latest-version") + if err != nil { + return errors.WrapError("failed to check latest version", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("failed to fetch latest-version: status %d", resp.StatusCode) + } + + versionBytes, err := io.ReadAll(resp.Body) + if err != nil { + return errors.WrapError("failed to read version", err) + } + version := strings.TrimSpace(string(versionBytes)) + if version == "" { + return fmt.Errorf("empty version from latest-version") + } + + // Determine OS and arch + goos := runtime.GOOS + goarch := runtime.GOARCH + + ext := "tar.gz" + if goos == "windows" { + ext = "zip" + } + + assetName := fmt.Sprintf("major_%s_%s_%s.%s", version, goos, goarch, ext) + checksumName := fmt.Sprintf("major_%s_checksums.txt", version) + downloadURL := fmt.Sprintf("%s/%s/%s", s3Bucket, version, assetName) + checksumURL := fmt.Sprintf("%s/%s/%s", s3Bucket, version, checksumName) + + cmd.Println(stepStyle.Render(fmt.Sprintf("▸ Downloading major v%s...", version))) + + tmpDir, err := os.MkdirTemp("", "major-update-*") + if err != nil { + return errors.WrapError("failed to create temp directory", err) + } + defer os.RemoveAll(tmpDir) + + // Download asset + assetPath := filepath.Join(tmpDir, assetName) + if err := downloadFile(downloadURL, assetPath); err != nil { + return errors.WrapError("failed to download binary", err) + } + + // Download and verify checksum + cmd.Println(stepStyle.Render("▸ Verifying checksum...")) + checksumPath := filepath.Join(tmpDir, "checksums.txt") + if err := downloadFile(checksumURL, checksumPath); err != nil { + return errors.WrapError("failed to download checksums", err) + } + + if err := verifyChecksum(assetPath, assetName, checksumPath); err != nil { + return errors.WrapError("checksum verification failed", err) + } + + // Extract + binaryName := "major" + if goos == "windows" { + binaryName = "major.exe" + if err := extractZip(assetPath, tmpDir); err != nil { + return errors.WrapError("failed to extract zip", err) + } + } else { + if err := extractTarGz(assetPath, tmpDir); err != nil { + return errors.WrapError("failed to extract tar.gz", err) + } + } + + // Determine install location + exe, err := os.Executable() + if err != nil { + return errors.WrapError("failed to get executable path", err) + } + exe, _ = filepath.EvalSymlinks(exe) + + // Pre-promotion health check: run the new binary to verify it works + // and reports the expected version before swapping it in. + srcPath := filepath.Join(tmpDir, binaryName) + cmd.Println(stepStyle.Render("▸ Verifying new binary...")) + if err := healthCheckBinary(srcPath, version); err != nil { + return errors.WrapError("new binary failed health check", err) + } + + // Replace the binary + if err := replaceBinary(exe, srcPath); err != nil { + return errors.WrapError("failed to replace binary", err) + } + + return nil +} + +func downloadFile(url, dst string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download %s failed with status %d", url, resp.StatusCode) + } + + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(f, resp.Body) + return err +} + +func verifyChecksum(assetPath, assetName, checksumPath string) error { + data, err := os.ReadFile(checksumPath) + if err != nil { + return err + } + + var expected string + for _, line := range strings.Split(string(data), "\n") { + if strings.Contains(line, assetName) { + parts := strings.Fields(line) + if len(parts) >= 1 { + expected = parts[0] + } + break + } + } + + if expected == "" { + return fmt.Errorf("no checksum found for %s", assetName) + } + + f, err := os.Open(assetPath) + if err != nil { + return err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + + actual := fmt.Sprintf("%x", h.Sum(nil)) + if !strings.EqualFold(actual, expected) { + return fmt.Errorf("expected %s, got %s", expected, actual) + } + + return nil +} + +func healthCheckBinary(path, expectedVersion string) error { + // Make sure it's executable on Unix + if runtime.GOOS != "windows" { + if err := os.Chmod(path, 0755); err != nil { + return err + } + } + + out, err := exec.Command(path, "--version").CombinedOutput() + if err != nil { + return fmt.Errorf("binary failed to execute: %w (output: %s)", err, string(out)) + } + + if !strings.Contains(string(out), expectedVersion) { + return fmt.Errorf("version mismatch: expected %s, got %q", expectedVersion, strings.TrimSpace(string(out))) + } + + return nil +} + +func extractTarGz(src, dst string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gr.Close() + + tr := tar.NewReader(gr) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if header.Typeflag != tar.TypeReg { + continue + } + outPath := filepath.Join(dst, filepath.Base(header.Name)) + outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(outFile, tr); err != nil { + outFile.Close() + return err + } + outFile.Close() + } + return nil +} + +func extractZip(src, dst string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + rc, err := f.Open() + if err != nil { + return err + } + outPath := filepath.Join(dst, filepath.Base(f.Name)) + outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY, f.Mode()) + if err != nil { + rc.Close() + return err + } + if _, err := io.Copy(outFile, rc); err != nil { + outFile.Close() + rc.Close() + return err + } + outFile.Close() + rc.Close() + } + return nil +} + +func replaceBinary(dst, src string) error { + if runtime.GOOS == "windows" { + return replaceBinaryWindows(dst, src) + } + + // Unix: write to temp file next to dst, then atomic rename. + tmpDst := dst + ".new" + if err := copyFile(src, tmpDst, 0755); err != nil { + return err + } + return os.Rename(tmpDst, dst) +} + +func replaceBinaryWindows(dst, src string) error { + // On Windows, a running .exe can be renamed but not overwritten. + // Strategy: + // 1. Copy new binary to dst.new + // 2. Rename running dst -> dst.old + // 3. Rename dst.new -> dst + // If step 3 fails, rename dst.old back to dst. + // The .old file is left behind (can't delete a running exe); cleaned on next update. + + newPath := dst + ".new" + oldPath := dst + ".old" + + // Clean up artifacts from any prior update + os.Remove(newPath) + os.Remove(oldPath) + + // Step 1: write new binary to .new + if err := copyFile(src, newPath, 0755); err != nil { + return fmt.Errorf("failed to write new binary: %w", err) + } + + // Step 2: move running binary out of the way + if err := os.Rename(dst, oldPath); err != nil { + os.Remove(newPath) + return fmt.Errorf("failed to rename running binary: %w", err) + } + + // Step 3: move new binary into place + if err := os.Rename(newPath, dst); err != nil { + // Rollback: restore original + os.Rename(oldPath, dst) + return fmt.Errorf("failed to install new binary: %w", err) + } + + return nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..0706cb3 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,121 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Installs the Major CLI on Windows. +.DESCRIPTION + Downloads the latest Major CLI release from S3, verifies its checksum, + extracts the binary to ~/.major/bin, and runs shell integration setup. +.EXAMPLE + irm https://major-cli-releases.s3.us-west-1.amazonaws.com/install.ps1 | iex +#> + +$ErrorActionPreference = 'Stop' + +# --- Configuration --- +$Binary = 'major' +$InstallDir = Join-Path $env:USERPROFILE '.major\bin' +$S3BucketUrl = 'https://major-cli-releases.s3.us-west-1.amazonaws.com' + +# --- Helpers --- +function Write-Step { param([string]$Msg) Write-Host " > $Msg" -ForegroundColor Cyan } +function Write-Ok { param([string]$Msg) Write-Host " + $Msg" -ForegroundColor Green } +function Write-Fail { param([string]$Msg) Write-Host " x $Msg" -ForegroundColor Red } + +Write-Host "`nMajor CLI Installer`n" -ForegroundColor White + +# --- Detect architecture --- +$Arch = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) { + 'X64' { 'amd64' } + 'Arm64' { 'arm64' } + default { Write-Fail "Architecture $_ not supported"; exit 1 } +} + +$OS = 'windows' + +# --- Get latest version --- +Write-Step 'Finding latest release...' +$Version = (Invoke-RestMethod -Uri "$S3BucketUrl/latest-version").Trim() +if (-not $Version) { + Write-Fail 'Could not find latest release version' + exit 1 +} + +# --- Download --- +$AssetName = "${Binary}_${Version}_${OS}_${Arch}.zip" +$ChecksumName = "${Binary}_${Version}_checksums.txt" +$DownloadUrl = "$S3BucketUrl/$Version/$AssetName" +$ChecksumUrl = "$S3BucketUrl/$Version/$ChecksumName" + +Write-Step "Downloading ${Binary} v${Version}..." + +$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) +New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null + +try { + Invoke-WebRequest -Uri $DownloadUrl -OutFile (Join-Path $TmpDir $AssetName) -UseBasicParsing + Invoke-WebRequest -Uri $ChecksumUrl -OutFile (Join-Path $TmpDir 'checksums.txt') -UseBasicParsing +} catch { + Write-Fail "Failed to download: $_" + Remove-Item -Recurse -Force $TmpDir + exit 1 +} + +# --- Verify checksum --- +Write-Step 'Verifying checksum...' + +$Checksums = Get-Content (Join-Path $TmpDir 'checksums.txt') +$Expected = ($Checksums | Where-Object { $_ -match $AssetName }) -replace '\s+.*$', '' + +if (-not $Expected) { + Write-Fail "Could not find checksum for $AssetName" + Remove-Item -Recurse -Force $TmpDir + exit 1 +} + +$Actual = (Get-FileHash -Path (Join-Path $TmpDir $AssetName) -Algorithm SHA256).Hash.ToLower() + +if ($Expected -ne $Actual) { + Write-Fail 'Checksum verification failed!' + Write-Host " Expected: $Expected" + Write-Host " Actual: $Actual" + Remove-Item -Recurse -Force $TmpDir + exit 1 +} + +Write-Ok 'Checksum verified' + +# --- Extract and install --- +Write-Step "Installing to $InstallDir..." + +Expand-Archive -Path (Join-Path $TmpDir $AssetName) -DestinationPath $TmpDir -Force + +New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +Copy-Item -Path (Join-Path $TmpDir "$Binary.exe") -Destination (Join-Path $InstallDir "$Binary.exe") -Force + +# Cleanup +Remove-Item -Recurse -Force $TmpDir + +# --- Shell integration --- +Write-Step 'Setting up shell integration...' +& (Join-Path $InstallDir "$Binary.exe") install + +# --- Verify --- +Write-Step 'Verifying installation...' +$InstalledVersion = & (Join-Path $InstallDir "$Binary.exe") --version 2>&1 | Select-Object -First 1 + +if ($LASTEXITCODE -ne 0) { + Write-Fail "Installed binary failed to execute (exit $LASTEXITCODE). Output: $InstalledVersion" + Write-Host " Check that $InstallDir is in your PATH and the .exe is not blocked by antivirus." + exit 1 +} + +if ($InstalledVersion -notmatch [regex]::Escape($Version)) { + Write-Fail "Version mismatch. Expected $Version, got: $InstalledVersion" + exit 1 +} + +Write-Ok "Successfully installed ${Binary} v${Version}" + +Write-Host "`nWelcome to Major!`n" -ForegroundColor Green +Write-Host "Get started by running:`n" +Write-Host " major user login Log in to your Major account" -ForegroundColor White diff --git a/install.sh b/install.sh index df4e4c0..c1db307 100755 --- a/install.sh +++ b/install.sh @@ -156,7 +156,16 @@ print_step "Setting up shell integration..." print_step "Verifying installation..." # We verify using the absolute path since PATH might not be updated in the current shell yet -INSTALLED_VERSION=$("$INSTALL_DIR/$BINARY" --version 2>&1 | head -n 1 || echo "unknown") +if ! INSTALLED_VERSION=$("$INSTALL_DIR/$BINARY" --version 2>&1 | head -n 1); then + print_error "Installed binary failed to execute: $INSTALLED_VERSION" + exit 1 +fi + +if ! echo "$INSTALLED_VERSION" | grep -q "$VERSION"; then + print_error "Version mismatch. Expected $VERSION, got: $INSTALLED_VERSION" + exit 1 +fi + print_success "Successfully installed ${BINARY} v${VERSION}" # Print welcome message diff --git a/plugins/major/.mcp.json b/plugins/major/.mcp.json index ac52af7..544f2e1 100644 --- a/plugins/major/.mcp.json +++ b/plugins/major/.mcp.json @@ -2,11 +2,11 @@ "major-resources": { "type": "http", "url": "https://go-api.prod.major.build/cli/v1/mcp", - "headersHelper": "bash -c 'TOKEN=$(major user token 2>/dev/null); ORG=$(major org id 2>/dev/null); echo \"{\\\"Authorization\\\": \\\"Bearer $TOKEN\\\", \\\"x-major-org-id\\\": \\\"$ORG\\\"}\"'" + "headersHelper": "major mcp get-headers" }, "major-platform": { "type": "http", "url": "https://api.prod.major.build/mcp/cli", - "headersHelper": "bash -c 'TOKEN=$(major user token 2>/dev/null); ORG=$(major org id 2>/dev/null); echo \"{\\\"Authorization\\\": \\\"Bearer $TOKEN\\\", \\\"x-major-org-id\\\": \\\"$ORG\\\"}\"'" + "headersHelper": "major mcp get-headers" } } diff --git a/plugins/major/hooks/auto-approve.json b/plugins/major/hooks/auto-approve.json index 57aaba6..49258e3 100644 --- a/plugins/major/hooks/auto-approve.json +++ b/plugins/major/hooks/auto-approve.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/check-readonly.sh" + "command": "major mcp check-readonly-hook" } ] } diff --git a/plugins/major/scripts/check-readonly.ps1 b/plugins/major/scripts/check-readonly.ps1 new file mode 100644 index 0000000..e549233 --- /dev/null +++ b/plugins/major/scripts/check-readonly.ps1 @@ -0,0 +1,14 @@ +# PreToolUse hook that auto-approves read-only MCP tools. +# Reads tool_name from stdin JSON, checks via CLI. + +$InputJson = $input | Out-String +$Parsed = $InputJson | ConvertFrom-Json -ErrorAction SilentlyContinue + +if (-not $Parsed -or -not $Parsed.tool_name) { exit 0 } + +$ToolName = $Parsed.tool_name + +# Strip the MCP server prefix to get the actual tool name +$ActualTool = $ToolName -replace '^mcp__plugin_major_major-resources__', '' + +& major mcp check-readonly $ActualTool 2>$null diff --git a/plugins/major/scripts/get-headers.ps1 b/plugins/major/scripts/get-headers.ps1 new file mode 100644 index 0000000..34330af --- /dev/null +++ b/plugins/major/scripts/get-headers.ps1 @@ -0,0 +1,26 @@ +# Find major CLI - check common install locations +$Major = $null +$Candidates = @( + (Join-Path $env:USERPROFILE '.major\bin\major.exe'), + (Join-Path $env:USERPROFILE 'go\bin\major.exe') +) + +foreach ($p in $Candidates) { + if (Test-Path $p) { + $Major = $p + break + } +} + +if (-not $Major) { + $Major = Get-Command major -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source +} + +if (-not $Major) { exit 1 } + +$Token = & $Major user token 2>$null +$Org = & $Major org id 2>$null + +if (-not $Token -or -not $Org) { exit 1 } + +Write-Output "{`"Authorization`": `"Bearer $Token`", `"x-major-org-id`": `"$Org`"}"