Skip to content

Commit a9f34ef

Browse files
authored
feat: add update command with self-update, verification, and rollback (larksuite#391)
1 parent ea7a730 commit a9f34ef

14 files changed

Lines changed: 1746 additions & 10 deletions

File tree

cmd/doctor/doctor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ func checkCLIUpdate() []checkResult {
238238
if update.IsNewer(latest, current) {
239239
return []checkResult{warn("cli_update",
240240
fmt.Sprintf("%s → %s available", current, latest),
241-
"run: npm update -g @larksuite/cli")}
241+
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
242242
}
243243
return []checkResult{pass("cli_update", latest+" (up to date)")}
244244
}

cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/larksuite/cli/cmd/profile"
2323
"github.com/larksuite/cli/cmd/schema"
2424
"github.com/larksuite/cli/cmd/service"
25+
cmdupdate "github.com/larksuite/cli/cmd/update"
2526
internalauth "github.com/larksuite/cli/internal/auth"
2627
"github.com/larksuite/cli/internal/build"
2728
"github.com/larksuite/cli/internal/cmdutil"
@@ -118,6 +119,7 @@ func Execute() int {
118119
rootCmd.AddCommand(api.NewCmdApi(f, nil))
119120
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
120121
rootCmd.AddCommand(completion.NewCmdCompletion(f))
122+
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
121123
service.RegisterServiceCommands(rootCmd, f)
122124
shortcuts.RegisterShortcuts(rootCmd, f)
123125

cmd/update/update.go

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package cmdupdate
5+
6+
import (
7+
"fmt"
8+
"runtime"
9+
"strings"
10+
11+
"github.com/spf13/cobra"
12+
13+
"github.com/larksuite/cli/internal/build"
14+
"github.com/larksuite/cli/internal/cmdutil"
15+
"github.com/larksuite/cli/internal/output"
16+
"github.com/larksuite/cli/internal/selfupdate"
17+
"github.com/larksuite/cli/internal/update"
18+
)
19+
20+
const (
21+
repoURL = "https://github.com/larksuite/cli"
22+
maxNpmOutput = 2000
23+
osWindows = "windows"
24+
)
25+
26+
// Overridable for testing.
27+
var (
28+
fetchLatest = func() (string, error) { return update.FetchLatest() }
29+
currentVersion = func() string { return build.Version }
30+
currentOS = runtime.GOOS
31+
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
32+
)
33+
34+
func isWindows() bool { return currentOS == osWindows }
35+
36+
func releaseURL(version string) string {
37+
return repoURL + "/releases/tag/v" + strings.TrimPrefix(version, "v")
38+
}
39+
40+
func changelogURL() string { return repoURL + "/blob/main/CHANGELOG.md" }
41+
42+
// --- Terminal symbols (ASCII fallback on Windows) ---
43+
44+
func symOK() string {
45+
if isWindows() {
46+
return "[OK]"
47+
}
48+
return "✓"
49+
}
50+
51+
func symFail() string {
52+
if isWindows() {
53+
return "[FAIL]"
54+
}
55+
return "✗"
56+
}
57+
58+
func symWarn() string {
59+
if isWindows() {
60+
return "[WARN]"
61+
}
62+
return "⚠"
63+
}
64+
65+
func symArrow() string {
66+
if isWindows() {
67+
return "->"
68+
}
69+
return "→"
70+
}
71+
72+
// --- Command ---
73+
74+
// UpdateOptions holds inputs for the update command.
75+
type UpdateOptions struct {
76+
Factory *cmdutil.Factory
77+
JSON bool
78+
Force bool
79+
Check bool
80+
}
81+
82+
// NewCmdUpdate creates the update command.
83+
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
84+
opts := &UpdateOptions{Factory: f}
85+
86+
cmd := &cobra.Command{
87+
Use: "update",
88+
Short: "Update lark-cli to the latest version",
89+
Long: `Update lark-cli to the latest version.
90+
91+
Detects the installation method automatically:
92+
- npm install: runs npm install -g @larksuite/cli@<version>
93+
- manual/other: shows GitHub Releases download URL
94+
95+
Use --json for structured output (for AI agents and scripts).
96+
Use --check to only check for updates without installing.`,
97+
RunE: func(cmd *cobra.Command, args []string) error {
98+
return updateRun(opts)
99+
},
100+
}
101+
cmdutil.DisableAuthCheck(cmd)
102+
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
103+
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
104+
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
105+
106+
return cmd
107+
}
108+
109+
func updateRun(opts *UpdateOptions) error {
110+
io := opts.Factory.IOStreams
111+
cur := currentVersion()
112+
updater := newUpdater()
113+
114+
updater.CleanupStaleFiles()
115+
output.PendingNotice = nil
116+
117+
// 1. Fetch latest version
118+
latest, err := fetchLatest()
119+
if err != nil {
120+
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
121+
}
122+
123+
// 2. Validate version format
124+
if update.ParseVersion(latest) == nil {
125+
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
126+
}
127+
128+
// 3. Compare versions
129+
if !opts.Force && !update.IsNewer(latest, cur) {
130+
if opts.JSON {
131+
output.PrintJson(io.Out, map[string]interface{}{
132+
"ok": true, "previous_version": cur, "current_version": cur,
133+
"latest_version": latest, "action": "already_up_to_date",
134+
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
135+
})
136+
return nil
137+
}
138+
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
139+
return nil
140+
}
141+
142+
// 4. Detect installation method
143+
detect := updater.DetectInstallMethod()
144+
145+
// 5. --check
146+
if opts.Check {
147+
return reportCheckResult(opts, io, cur, latest, detect.CanAutoUpdate())
148+
}
149+
150+
// 6. Execute update
151+
if !detect.CanAutoUpdate() {
152+
return doManualUpdate(opts, io, cur, latest, detect)
153+
}
154+
return doNpmUpdate(opts, io, cur, latest, updater)
155+
}
156+
157+
// --- Output helpers ---
158+
159+
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
160+
msg := fmt.Sprintf(format, args...)
161+
if opts.JSON {
162+
output.PrintJson(io.Out, map[string]interface{}{
163+
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
164+
})
165+
return output.ErrBare(exitCode)
166+
}
167+
return output.Errorf(exitCode, errType, "%s", msg)
168+
}
169+
170+
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
171+
if opts.JSON {
172+
output.PrintJson(io.Out, map[string]interface{}{
173+
"ok": true, "previous_version": cur, "current_version": cur,
174+
"latest_version": latest, "action": "update_available",
175+
"auto_update": canAutoUpdate,
176+
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
177+
"url": releaseURL(latest), "changelog": changelogURL(),
178+
})
179+
return nil
180+
}
181+
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
182+
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
183+
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
184+
if canAutoUpdate {
185+
fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n")
186+
} else {
187+
fmt.Fprintf(io.ErrOut, "\nDownload the release above to update manually.\n")
188+
}
189+
return nil
190+
}
191+
192+
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult) error {
193+
reason := detect.ManualReason()
194+
if opts.JSON {
195+
output.PrintJson(io.Out, map[string]interface{}{
196+
"ok": true, "previous_version": cur, "latest_version": latest,
197+
"action": "manual_required",
198+
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
199+
"url": releaseURL(latest), "changelog": changelogURL(),
200+
})
201+
return nil
202+
}
203+
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
204+
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
205+
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
206+
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
207+
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
208+
fmt.Fprintf(io.ErrOut, "\nAfter updating, also update skills:\n npx -y skills add larksuite/cli -g -y\n")
209+
return nil
210+
}
211+
212+
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
213+
restore, err := updater.PrepareSelfReplace()
214+
if err != nil {
215+
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
216+
}
217+
218+
if !opts.JSON {
219+
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
220+
}
221+
222+
npmResult := updater.RunNpmInstall(latest)
223+
if npmResult.Err != nil {
224+
restore()
225+
combined := npmResult.CombinedOutput()
226+
if opts.JSON {
227+
output.PrintJson(io.Out, map[string]interface{}{
228+
"ok": false, "error": map[string]interface{}{
229+
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
230+
"detail": selfupdate.Truncate(combined, maxNpmOutput),
231+
"hint": permissionHint(combined),
232+
},
233+
})
234+
return output.ErrBare(output.ExitAPI)
235+
}
236+
if npmResult.Stdout.Len() > 0 {
237+
fmt.Fprint(io.ErrOut, npmResult.Stdout.String())
238+
}
239+
if npmResult.Stderr.Len() > 0 {
240+
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
241+
}
242+
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
243+
if hint := permissionHint(combined); hint != "" {
244+
fmt.Fprintf(io.ErrOut, " %s\n", hint)
245+
}
246+
return output.ErrBare(output.ExitAPI)
247+
}
248+
249+
// Verify the new binary is functional before proceeding.
250+
// If corrupt, restore the previous version from .old.
251+
if err := updater.VerifyBinary(latest); err != nil {
252+
restore()
253+
msg := fmt.Sprintf("new binary verification failed: %s", err)
254+
hint := verificationFailureHint(updater, latest)
255+
if opts.JSON {
256+
output.PrintJson(io.Out, map[string]interface{}{
257+
"ok": false,
258+
"error": map[string]interface{}{"type": "update_error", "message": msg, "hint": hint},
259+
})
260+
return output.ErrBare(output.ExitAPI)
261+
}
262+
fmt.Fprintf(io.ErrOut, "\n%s %s\n", symFail(), msg)
263+
fmt.Fprintf(io.ErrOut, " %s\n", hint)
264+
return output.ErrBare(output.ExitAPI)
265+
}
266+
267+
// Skills update (best-effort).
268+
skillsResult := updater.RunSkillsUpdate()
269+
270+
if opts.JSON {
271+
result := map[string]interface{}{
272+
"ok": true, "previous_version": cur, "current_version": latest,
273+
"latest_version": latest, "action": "updated",
274+
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
275+
"url": releaseURL(latest), "changelog": changelogURL(),
276+
}
277+
if skillsResult.Err != nil {
278+
result["skills_warning"] = fmt.Sprintf("skills update failed: %s", skillsResult.Err)
279+
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
280+
result["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
281+
}
282+
}
283+
output.PrintJson(io.Out, result)
284+
return nil
285+
}
286+
287+
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
288+
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
289+
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
290+
if skillsResult.Err != nil {
291+
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %s\n", symWarn(), skillsResult.Err)
292+
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
293+
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, 500))
294+
}
295+
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
296+
} else {
297+
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
298+
}
299+
return nil
300+
}
301+
302+
func permissionHint(npmOutput string) string {
303+
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
304+
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
305+
}
306+
return ""
307+
}
308+
309+
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
310+
if updater.CanRestorePreviousVersion() {
311+
return "the previous version has been restored"
312+
}
313+
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
314+
}

0 commit comments

Comments
 (0)