Skip to content

Commit 637fc66

Browse files
authored
feat: Add update and version commands
feat: Add `update` and `version` commands
2 parents 6a375b5 + f22ebcb commit 637fc66

File tree

6 files changed

+519
-3
lines changed

6 files changed

+519
-3
lines changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,13 @@ variables:
116116
- `INFRACOST_CLI_PROVIDER_PLUGIN_AZURE_VERSION` — pin the Azure provider plugin version
117117
- `INFRACOST_CLI_PROVIDER_PLUGIN_GOOGLE_VERSION` — pin the Google provider plugin version
118118

119-
#### Auto-Update
119+
#### Updates
120120

121-
Set `INFRACOST_CLI_PLUGIN_AUTO_UPDATE=false` to disable automatic updates. When disabled, the CLI uses the latest cached
122-
version if one exists, and only downloads from the manifest if no cached version is found.
121+
Plugins auto-update by default. Set `INFRACOST_CLI_PLUGIN_AUTO_UPDATE=false` to disable automatic plugin updates. When disabled, the CLI uses the latest cached version if one exists, and only downloads from the manifest if no cached version is found.
122+
123+
To update the CLI itself, you can use the `update` command. This will update the CLI binary by downloading the latest release from GitHub. Note that this does not update plugins, which are managed separately as described above.
124+
125+
```bash
123126

124127
#### Local Plugin Overrides
125128

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) *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) *cobra.Command {
12+
return &cobra.Command{
13+
Use: "version",
14+
Short: "Show the current version",
15+
Args: cobra.NoArgs,
16+
RunE: func(_ *cobra.Command, _ []string) error {
17+
fmt.Println(version.Version)
18+
return nil
19+
},
20+
}
21+
}

internal/update/update.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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+
var newGitHubClient = func() *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 func() { _ = 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 func() { _ = rc.Close() }()
163+
return io.ReadAll(rc)
164+
}
165+
}
166+
return nil, fmt.Errorf("binary %q not found in archive", binaryName)
167+
}
168+
169+
var replaceBinary = func(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+
info, err := os.Stat(execPath)
180+
if err != nil {
181+
return err
182+
}
183+
184+
// Write new binary to a temp file in the same directory (ensures same filesystem for rename).
185+
dir := filepath.Dir(execPath)
186+
tmp, err := os.CreateTemp(dir, ".infracost-preview-update-*")
187+
if err != nil {
188+
return err
189+
}
190+
tmpPath := tmp.Name()
191+
192+
if _, err := tmp.Write(newBinary); err != nil {
193+
_ = tmp.Close()
194+
_ = os.Remove(tmpPath)
195+
return err
196+
}
197+
if err := tmp.Close(); err != nil {
198+
_ = os.Remove(tmpPath)
199+
return err
200+
}
201+
202+
// persist current permissions to the new file, so we respect the user's choice of perms
203+
if err := os.Chmod(tmpPath, info.Mode().Perm()); err != nil {
204+
_ = os.Remove(tmpPath)
205+
return err
206+
}
207+
208+
// Atomic rename.
209+
if err := os.Rename(tmpPath, execPath); err != nil {
210+
_ = os.Remove(tmpPath)
211+
return err
212+
}
213+
214+
return nil
215+
}
216+
217+
var ErrTokenNotFound = fmt.Errorf("github token not found")
218+
219+
func findGitHubToken() (string, error) {
220+
if tok := os.Getenv("GH_TOKEN"); tok != "" {
221+
return tok, nil
222+
}
223+
224+
if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
225+
return tok, nil
226+
}
227+
228+
cmd := exec.Command("gh", "auth", "token")
229+
cmd.Stderr = io.Discard
230+
output, err := cmd.Output()
231+
if err != nil {
232+
return "", err
233+
}
234+
token := strings.TrimSpace(string(output))
235+
if token != "" {
236+
return token, nil
237+
}
238+
239+
return "", ErrTokenNotFound
240+
}

0 commit comments

Comments
 (0)