Skip to content

Commit 9d1dc23

Browse files
committed
feat(update): add meta update subcommand
1 parent efe7cf4 commit 9d1dc23

6 files changed

Lines changed: 1352 additions & 0 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,29 @@ Or with Go:
1616
go install github.com/enriquefft/meta-cli/cmd/meta@latest
1717
```
1818

19+
## Updating
20+
21+
```sh
22+
# Check for a new version (no download)
23+
meta update --check
24+
25+
# Upgrade to the latest release in place
26+
meta update
27+
28+
# Pin to a specific version
29+
meta update --version 0.3.0
30+
```
31+
32+
`meta update` re-runs the official `install.sh` script pinned to the target
33+
tag, so the upgrade procedure is identical to a fresh install — same
34+
checksums, same install directory, same binary path. The CLI does not
35+
duplicate any download or verification logic.
36+
37+
If you installed via `go install`, run
38+
`go install github.com/enriquefft/meta-cli/cmd/meta@latest` to upgrade.
39+
`meta update` detects this case and prints the same hint instead of
40+
running the install script.
41+
1942
## Quick start
2043

2144
```sh

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func Execute(ctx context.Context, store *config.ConfigStore, client meta.Client)
7979
NewAdsCommand(deps),
8080
NewTargetingCommand(deps),
8181
NewServeCommand(deps),
82+
NewUpdateCommand(deps),
8283
)
8384

8485
// Resolve flags AFTER Cobra parses them, BEFORE any subcommand runs.

internal/cli/update.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/enriquefft/meta-cli/internal/meta"
9+
"github.com/enriquefft/meta-cli/internal/output"
10+
"github.com/enriquefft/meta-cli/internal/updater"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
// Indirection seams for tests. The update command depends on several
15+
// side-effectful operations (constructing an updater, reading the executable
16+
// path, resolving symlinks, classifying the install method). Routing every
17+
// call through a package-level var lets update_test.go substitute fakes
18+
// without touching production code paths — this mirrors the osExit pattern
19+
// established in syscalls.go.
20+
var (
21+
updateNewUpdater = updater.New
22+
updateExecutable = os.Executable
23+
updateEvalSymlinks = filepath.EvalSymlinks
24+
updateDetectMethod = updater.DetectInstallMethod
25+
)
26+
27+
// NewUpdateCommand creates the "meta update" command.
28+
//
29+
// The command deliberately does not reimplement any installer logic. It
30+
// inspects how meta-cli was installed, queries GitHub for the latest
31+
// release, and — for script-installed binaries — downloads and executes
32+
// install.sh pinned to the target tag. install.sh is the single source of
33+
// truth for downloading, checksum verification, extraction, and install
34+
// location; the CLI never duplicates any of that.
35+
func NewUpdateCommand(deps *Dependencies) *cobra.Command {
36+
var checkOnly bool
37+
var force bool
38+
var pinVersion string
39+
var apiBaseURL string
40+
var rawBaseURL string
41+
42+
cmd := &cobra.Command{
43+
Use: "update",
44+
Short: "Update meta-cli to the latest release",
45+
Long: "Check for and install the latest published release of meta-cli.\n\n" +
46+
"Delegates to the official install.sh script pinned to the target tag, " +
47+
"so the upgrade procedure is identical to a fresh install (same checksums, " +
48+
"same install directory).",
49+
RunE: func(cmd *cobra.Command, args []string) error {
50+
ctx := cmd.Context()
51+
52+
up := updateNewUpdater(updater.Config{
53+
APIBaseURL: apiBaseURL,
54+
RawBaseURL: rawBaseURL,
55+
})
56+
57+
// Resolve which version to install. If the user passed --version
58+
// we trust them and skip the "latest" lookup entirely; otherwise
59+
// compare the running binary against the GitHub latest release.
60+
var targetVersion string
61+
var status updater.Status
62+
if pinVersion != "" {
63+
targetVersion = updater.StripLeadingV(pinVersion)
64+
} else {
65+
var err error
66+
status, err = up.Check(ctx, Version)
67+
if err != nil {
68+
_ = output.PrintError(cmd.ErrOrStderr(), fmt.Errorf("checking for updates: %w", err))
69+
osExit(meta.ExitNetworkError)
70+
return nil
71+
}
72+
73+
if checkOnly {
74+
return output.Print(cmd.OutOrStdout(), status, deps.Format, deps.Fields)
75+
}
76+
77+
if status.UpToDate && !force {
78+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "meta %s is already the latest version\n", Version)
79+
return nil
80+
}
81+
targetVersion = status.Latest
82+
}
83+
84+
// Resolve the current binary's install directory from its own
85+
// path. EvalSymlinks is important here: on systems where the
86+
// user symlinks ~/.local/bin/meta into /usr/local/bin, we must
87+
// point install.sh at the real directory so the new binary
88+
// overwrites the old one instead of the symlink.
89+
execPath, err := updateExecutable()
90+
if err != nil {
91+
_ = output.PrintError(cmd.ErrOrStderr(), fmt.Errorf("resolving executable path: %w", err))
92+
osExit(meta.ExitConfigError)
93+
return nil
94+
}
95+
resolvedPath, err := updateEvalSymlinks(execPath)
96+
if err != nil {
97+
// If the binary is reachable but its symlink chain cannot
98+
// be walked (for example, a transient filesystem issue),
99+
// fall back to the unresolved path — that is strictly
100+
// better than failing the whole upgrade.
101+
resolvedPath = execPath
102+
}
103+
installDir := filepath.Dir(resolvedPath)
104+
105+
// Decide how to upgrade based on how the binary was installed.
106+
method := updateDetectMethod(resolvedPath)
107+
switch method {
108+
case updater.MethodWindows:
109+
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "meta update is not supported on Windows. Download a release manually from https://github.com/enriquefft/meta-cli/releases")
110+
osExit(meta.ExitConfigError)
111+
return nil
112+
case updater.MethodGoInstall:
113+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "meta-cli was installed via 'go install'.")
114+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Upgrade with: go install github.com/enriquefft/meta-cli/cmd/meta@latest")
115+
return nil
116+
case updater.MethodScript, updater.MethodUnknown:
117+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Updating meta to v%s in %s...\n", targetVersion, installDir)
118+
if err := up.RunInstallScript(ctx, targetVersion, installDir); err != nil {
119+
_ = output.PrintError(cmd.ErrOrStderr(), err)
120+
osExit(meta.ExitAPIError)
121+
return nil
122+
}
123+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "meta v%s installed\n", targetVersion)
124+
return nil
125+
}
126+
return nil
127+
},
128+
}
129+
130+
cmd.Flags().BoolVar(&checkOnly, "check", false, "Check for a new version without installing")
131+
cmd.Flags().BoolVar(&force, "force", false, "Reinstall even if already on the latest version")
132+
cmd.Flags().StringVar(&pinVersion, "version", "", "Pin to a specific version (e.g. 0.3.0)")
133+
cmd.Flags().StringVar(&apiBaseURL, "github-api-base-url", "", "Override GitHub API base URL (testing)")
134+
cmd.Flags().StringVar(&rawBaseURL, "github-raw-base-url", "", "Override GitHub raw base URL (testing)")
135+
_ = cmd.Flags().MarkHidden("github-api-base-url")
136+
_ = cmd.Flags().MarkHidden("github-raw-base-url")
137+
138+
// --check ("tell me if a new version exists") and --version ("install
139+
// this exact tag") express opposing intents — combining them used to
140+
// silently install the pinned version. Reject the combination at parse
141+
// time so the user gets an immediate error instead of unexpected work.
142+
cmd.MarkFlagsMutuallyExclusive("check", "version")
143+
144+
return cmd
145+
}

0 commit comments

Comments
 (0)