Skip to content

Commit fe71e44

Browse files
committed
feat: Add update command
1 parent c80bcb5 commit fe71e44

File tree

4 files changed

+275
-0
lines changed

4 files changed

+275
-0
lines changed

internal/cmds/update.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package cmds
2+
3+
import (
4+
"github.com/infracost/cli/internal/config"
5+
"github.com/infracost/cli/internal/update"
6+
"github.com/spf13/cobra"
7+
)
8+
9+
func Update(config *config.Config) *cobra.Command {
10+
return &cobra.Command{
11+
Use: "update",
12+
Short: "Update to the latest version",
13+
Args: cobra.NoArgs,
14+
RunE: func(cmd *cobra.Command, _ []string) error {
15+
return update.Update(cmd.Context())
16+
},
17+
}
18+
}

internal/cmds/version.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package cmds
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/infracost/cli/internal/config"
7+
"github.com/infracost/cli/version"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func Version(config *config.Config) *cobra.Command {
12+
return &cobra.Command{
13+
Use: "version",
14+
Short: "Show the current version",
15+
Args: cobra.NoArgs,
16+
RunE: func(cmd *cobra.Command, _ []string) error {
17+
fmt.Println(version.Version)
18+
return nil
19+
},
20+
}
21+
}

internal/update/update.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package update
2+
3+
import (
4+
"archive/tar"
5+
"archive/zip"
6+
"bytes"
7+
"compress/gzip"
8+
"context"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"os"
13+
"os/exec"
14+
"path/filepath"
15+
"runtime"
16+
"strings"
17+
"time"
18+
19+
"github.com/Masterminds/semver/v3"
20+
"github.com/google/go-github/v83/github"
21+
"github.com/infracost/cli/version"
22+
)
23+
24+
const (
25+
repoOwner = "infracost"
26+
repoName = "cli"
27+
)
28+
29+
func Update(ctx context.Context) error {
30+
31+
currentVersion, _ := semver.NewVersion(version.Version)
32+
33+
client := newGitHubClient()
34+
35+
release, _, err := client.Repositories.GetLatestRelease(ctx, repoOwner, repoName)
36+
if err != nil {
37+
return fmt.Errorf("failed to fetch latest release: %w", err)
38+
}
39+
40+
tag := release.GetTagName()
41+
latestVersion, err := semver.NewVersion(tag)
42+
if err != nil {
43+
return fmt.Errorf("cannot parse release version %q: %w", tag, err)
44+
}
45+
46+
if currentVersion != nil && !latestVersion.GreaterThan(currentVersion) {
47+
fmt.Printf("Already up to date (v%s).\n", currentVersion)
48+
return nil
49+
}
50+
51+
fmt.Printf("Updating %s → v%s...\n", version.Version, latestVersion)
52+
53+
assetName := expectedAssetName(latestVersion.String())
54+
var assetID int64
55+
for _, a := range release.Assets {
56+
if a.GetName() == assetName {
57+
assetID = a.GetID()
58+
break
59+
}
60+
}
61+
if assetID == 0 {
62+
return fmt.Errorf("no release asset found for %s/%s (expected %s)", runtime.GOOS, runtime.GOARCH, assetName)
63+
}
64+
65+
rc, _, err := client.Repositories.DownloadReleaseAsset(ctx, repoOwner, repoName, assetID, &http.Client{Timeout: 60 * time.Second})
66+
if err != nil {
67+
return fmt.Errorf("failed to download asset: %w", err)
68+
}
69+
assetData, err := io.ReadAll(rc)
70+
_ = rc.Close()
71+
if err != nil {
72+
return fmt.Errorf("failed to read asset: %w", err)
73+
}
74+
75+
for _, binaryName := range getBinaryNames() {
76+
77+
binaryData, err := extractBinary(assetName, assetData, binaryName)
78+
if err != nil {
79+
continue
80+
}
81+
82+
if err := replaceBinary(binaryData); err != nil {
83+
return fmt.Errorf("failed to replace binary: %w", err)
84+
}
85+
86+
fmt.Printf("Updated to v%s.\n", latestVersion)
87+
return nil
88+
}
89+
90+
return fmt.Errorf("no suitable binary found in asset %q", assetName)
91+
}
92+
93+
func getBinaryNames() []string {
94+
candidates := []string{"infracost-preview", "infracost"}
95+
output := make([]string, 0, len(candidates))
96+
for _, candidate := range candidates {
97+
if runtime.GOOS == "windows" {
98+
candidate += ".exe"
99+
}
100+
output = append(output, candidate)
101+
}
102+
return output
103+
}
104+
105+
func newGitHubClient() *github.Client {
106+
token, err := findGitHubToken()
107+
if err == nil && token != "" {
108+
return github.NewClient(nil).WithAuthToken(token)
109+
}
110+
return github.NewClient(nil)
111+
}
112+
113+
func expectedAssetName(ver string) string {
114+
ext := "tar.gz"
115+
if runtime.GOOS == "windows" {
116+
ext = "zip"
117+
}
118+
return fmt.Sprintf("infracost-preview_%s_%s_%s.%s", ver, runtime.GOOS, runtime.GOARCH, ext)
119+
}
120+
121+
func extractBinary(assetName string, data []byte, binaryName string) ([]byte, error) {
122+
if strings.HasSuffix(assetName, ".zip") {
123+
return extractFromZip(data, binaryName)
124+
}
125+
return extractFromTarGz(data, binaryName)
126+
}
127+
128+
func extractFromTarGz(data []byte, binaryName string) ([]byte, error) {
129+
gz, err := gzip.NewReader(bytes.NewReader(data))
130+
if err != nil {
131+
return nil, err
132+
}
133+
defer gz.Close()
134+
135+
tr := tar.NewReader(gz)
136+
for {
137+
hdr, err := tr.Next()
138+
if err == io.EOF {
139+
break
140+
}
141+
if err != nil {
142+
return nil, err
143+
}
144+
if filepath.Base(hdr.Name) == binaryName {
145+
return io.ReadAll(tr)
146+
}
147+
}
148+
return nil, fmt.Errorf("binary %q not found in archive", binaryName)
149+
}
150+
151+
func extractFromZip(data []byte, binaryName string) ([]byte, error) {
152+
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
153+
if err != nil {
154+
return nil, err
155+
}
156+
for _, f := range r.File {
157+
if filepath.Base(f.Name) == binaryName {
158+
rc, err := f.Open()
159+
if err != nil {
160+
return nil, err
161+
}
162+
defer rc.Close()
163+
return io.ReadAll(rc)
164+
}
165+
}
166+
return nil, fmt.Errorf("binary %q not found in archive", binaryName)
167+
}
168+
169+
func replaceBinary(newBinary []byte) error {
170+
execPath, err := os.Executable()
171+
if err != nil {
172+
return err
173+
}
174+
execPath, err = filepath.EvalSymlinks(execPath)
175+
if err != nil {
176+
return err
177+
}
178+
179+
// Write new binary to a temp file in the same directory (ensures same filesystem for rename).
180+
dir := filepath.Dir(execPath)
181+
tmp, err := os.CreateTemp(dir, ".infracost-preview-update-*")
182+
if err != nil {
183+
return err
184+
}
185+
tmpPath := tmp.Name()
186+
187+
if _, err := tmp.Write(newBinary); err != nil {
188+
tmp.Close()
189+
os.Remove(tmpPath)
190+
return err
191+
}
192+
if err := tmp.Close(); err != nil {
193+
os.Remove(tmpPath)
194+
return err
195+
}
196+
197+
if err := os.Chmod(tmpPath, 0o755); err != nil {
198+
os.Remove(tmpPath)
199+
return err
200+
}
201+
202+
// Atomic rename.
203+
if err := os.Rename(tmpPath, execPath); err != nil {
204+
os.Remove(tmpPath)
205+
return err
206+
}
207+
208+
return nil
209+
}
210+
211+
var ErrTokenNotFound = fmt.Errorf("github token not found")
212+
213+
func findGitHubToken() (string, error) {
214+
if tok := os.Getenv("GH_TOKEN"); tok != "" {
215+
return tok, nil
216+
}
217+
218+
if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
219+
return tok, nil
220+
}
221+
222+
cmd := exec.Command("gh", "auth", "token")
223+
cmd.Stderr = io.Discard
224+
output, err := cmd.Output()
225+
if err != nil {
226+
return "", err
227+
}
228+
token := strings.TrimSpace(string(output))
229+
if token != "" {
230+
return token, nil
231+
}
232+
233+
return "", ErrTokenNotFound
234+
}

main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ func run() (exitCode int) {
6363
cmd.AddCommand(cmds.Login(cfg))
6464
cmd.AddCommand(cmds.Logout(cfg))
6565
cmd.AddCommand(cmds.Price(cfg))
66+
cmd.AddCommand(cmds.Update(cfg))
67+
cmd.AddCommand(cmds.Version(cfg))
6668

6769
diags.Merge(process.PreProcess(cfg, cmd.PersistentFlags()))
6870
if diags.Critical().Len() > 0 {

0 commit comments

Comments
 (0)