Skip to content

Commit 3a5ea32

Browse files
somanshreddyclaude
andauthored
update: add --check and stop offering a downgrade to from-source builds (PRINFRA-1331) (#366)
update: add --check, and stop offering a downgrade to from-source builds validateCurrentVersion rejected only "", "dev" and "test", so a git-describe build like v0.8.1-6-gabc1234-dirty passed. That string is valid semver whose prerelease orders it BELOW v0.8.1, so the newest release compared as newer and `heygen update` replaced a local build with older code. It now accepts only a stable tag or a dev prerelease, keyed on the "dev." marker rather than the stamp's shape so a change to dev-release.yml's timestamp cannot break the dev channel. --check answers what an update would do without doing it. Where update treats a non-release build and a package-manager install as terminal errors, --check reports them as fields, so a caller branches on update_available and release_build instead of parsing an error message. It reuses the existing release source, so it cannot disagree with what update would then install. SKILL.md tells agents to surface an available update rather than take it unprompted, since a new version can change command output mid-task. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a58abe1 commit 3a5ea32

4 files changed

Lines changed: 404 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ You only need a HeyGen API key — see [Authenticate](#authenticate) below.
4444

4545
```bash
4646
heygen update # install the latest version
47+
heygen update --check # report whether one is available, without installing
4748
```
4849

4950
## Shell completion

‎SKILL.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ heygen video get --response-schema
135135

136136
- The CLI automatically retries 429 and selected transient 5xx (500/502/503/504) on
137137
retry-eligible requests.
138-
- Use `heygen update` to check for and install a newer CLI release.
138+
- Use `heygen update` to install a newer CLI release, or `heygen update --check` to
139+
report whether one exists without installing it. `--check` answers on stdout with
140+
`update_available`, `current`, `latest`, `channel`, `install_method`, and
141+
`release_build`. Branch on `update_available`, which means a newer release
142+
exists upstream, not that this install can fetch it: an `install_method` of
143+
`homebrew` or `npm` must update through that manager, and an empty one means
144+
the method could not be determined. When `release_build` is false the build is
145+
local and no update can be offered. Neither command needs an API key.
146+
If `update_available` is true, tell the user rather than updating unprompted:
147+
a new CLI version can change command output mid-task.
139148
- Video download writes to `{video-id}.mp4` by default. Override with `--output-path`. Errors if the file already exists; use `--force` to overwrite.
140149
- For the full API reference (concepts, limits, pricing), see https://developers.heygen.com

‎cmd/heygen/update.go‎

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"os/exec"
88
"path/filepath"
9+
"regexp"
910
"strings"
1011

1112
"github.com/Masterminds/semver/v3"
@@ -25,6 +26,27 @@ type updateResponse struct {
2526
Message string `json:"message"`
2627
}
2728

29+
type updateCheckResponse struct {
30+
Current string `json:"current"`
31+
Latest string `json:"latest"`
32+
UpdateAvailable bool `json:"update_available"`
33+
ReleaseBuild bool `json:"release_build"`
34+
InstallMethod string `json:"install_method"`
35+
Channel string `json:"channel"`
36+
Message string `json:"message"`
37+
}
38+
39+
// releaseTaggedVersion matches a stable tag or a dev prerelease. The dev suffix
40+
// stays loose on purpose: dev-release.yml stamps a timestamp while the fixtures
41+
// below carry a timestamp plus a sha, so the marker is what identifies the
42+
// channel, not the stamp's shape.
43+
//
44+
// It is a shape check rather than a semver parse because git-describe is what it
45+
// must reject, and "v0.8.1-6-gabc1234-dirty" parses fine as semver while
46+
// ordering BELOW v0.8.1. Accepting it makes the newest release compare as newer,
47+
// so an "update" installs older code than the build already running.
48+
var releaseTaggedVersion = regexp.MustCompile(`^v\d+\.\d+\.\d+(-dev\.[0-9A-Za-z.]+)?$`)
49+
2850
type updateRelease struct {
2951
Version string
3052
raw *selfupdate.Release
@@ -97,13 +119,77 @@ func newUpdateCmd(ctx *cmdContext) *cobra.Command {
97119
Annotations: map[string]string{"skipAuth": "true"},
98120
RunE: func(cmd *cobra.Command, args []string) error {
99121
targetVersion, _ := cmd.Flags().GetString("version")
122+
check, _ := cmd.Flags().GetBool("check")
123+
if check {
124+
if cmd.Flags().Changed("version") {
125+
return clierrors.NewUsage("--check reports what an update would do; it cannot be combined with --version")
126+
}
127+
return runUpdateCheck(ctx)
128+
}
100129
return runUpdate(ctx, targetVersion)
101130
},
102131
}
103132
cmd.Flags().String("version", "", "Update to a specific version (e.g., v0.1.0)")
133+
cmd.Flags().Bool("check", false, "Report whether an update is available without installing it")
104134
return cmd
105135
}
106136

137+
// runUpdateCheck answers "what would heygen update do", without doing it.
138+
//
139+
// It reports rather than refuses, which is the whole difference from runUpdate:
140+
// a non-release build and a package-manager install are both terminal errors
141+
// there, but here they are the answer, so a caller can branch on the fields
142+
// instead of parsing an error.
143+
func runUpdateCheck(ctx *cmdContext) error {
144+
raw := updateBuildVersion(ctx)
145+
resp := updateCheckResponse{Current: raw}
146+
147+
// Best-effort: an install method we cannot determine still permits the
148+
// version comparison, which is the part the caller asked for.
149+
if method, _, err := detectInstallMethod(); err == nil {
150+
resp.InstallMethod = method
151+
}
152+
153+
current, err := validateCurrentVersion(raw)
154+
if err != nil {
155+
resp.Message = "not a release build, so no update can be offered"
156+
return emitUpdateCheck(ctx, resp)
157+
}
158+
resp.Current = current
159+
resp.ReleaseBuild = true
160+
resp.Channel = updateChannel(current)
161+
162+
updater, err := newReleaseUpdater(resp.Channel == "dev")
163+
if err != nil {
164+
return err
165+
}
166+
rel, found, err := updater.DetectLatest(context.Background())
167+
if err != nil {
168+
return clierrors.New(fmt.Sprintf("failed to check for updates: %v", err))
169+
}
170+
if !found {
171+
resp.Message = fmt.Sprintf("no %s release found for this platform", resp.Channel)
172+
return emitUpdateCheck(ctx, resp)
173+
}
174+
175+
resp.Latest = rel.Version
176+
resp.UpdateAvailable = isVersionGreater(rel.Version, current)
177+
if resp.UpdateAvailable {
178+
resp.Message = fmt.Sprintf("heygen %s is available; you have %s", rel.Version, current)
179+
} else {
180+
resp.Message = fmt.Sprintf("heygen is up to date at %s", current)
181+
}
182+
return emitUpdateCheck(ctx, resp)
183+
}
184+
185+
func emitUpdateCheck(ctx *cmdContext, resp updateCheckResponse) error {
186+
data, err := marshalData(resp)
187+
if err != nil {
188+
return err
189+
}
190+
return ctx.formatter.Data(data, "", nil)
191+
}
192+
107193
func runUpdate(ctx *cmdContext, targetVersion string) error {
108194
current, err := validateCurrentVersion(updateBuildVersion(ctx))
109195
if err != nil {
@@ -138,10 +224,7 @@ func runUpdate(ctx *cmdContext, targetVersion string) error {
138224
return clierrors.New(fmt.Sprintf("failed to resolve executable path: %v", err))
139225
}
140226

141-
// Auto-detect update channel from current version: dev builds track
142-
// dev prereleases, stable builds track stable releases only.
143-
isDevBuild := strings.Contains(current, "-dev.")
144-
updater, err := newReleaseUpdater(isDevBuild)
227+
updater, err := newReleaseUpdater(updateChannel(current) == "dev")
145228
if err != nil {
146229
return err
147230
}
@@ -190,16 +273,25 @@ func runUpdate(ctx *cmdContext, targetVersion string) error {
190273
}
191274

192275
func validateCurrentVersion(raw string) (string, error) {
193-
if raw == "" || raw == "dev" || raw == "test" {
276+
version := canonicalVersion(raw)
277+
if !releaseTaggedVersion.MatchString(version) {
194278
return "", clierrors.New("current build version is not release-tagged; reinstall from a release build to use heygen update")
195279
}
196-
version := canonicalVersion(raw)
197280
if _, err := semver.NewVersion(strings.TrimPrefix(version, "v")); err != nil {
198281
return "", clierrors.New(fmt.Sprintf("current build version %q is not a valid semantic version", raw))
199282
}
200283
return version, nil
201284
}
202285

286+
// updateChannel selects the release track, which becomes the updater's
287+
// prerelease flag: a dev build sees dev prereleases, a stable build never does.
288+
func updateChannel(version string) string {
289+
if strings.Contains(version, "-dev.") {
290+
return "dev"
291+
}
292+
return "stable"
293+
}
294+
203295
func validateTargetVersion(raw string) error {
204296
if !strings.HasPrefix(raw, "v") {
205297
return clierrors.NewUsage("version must include the leading v (for example: v0.1.0)")

0 commit comments

Comments
 (0)