Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
264f1bf
feat: scaffold upgrade command with version check
liangshuo-1 Apr 9, 2026
7cab417
feat: implement upgrade detection and npm execution
liangshuo-1 Apr 9, 2026
8753432
fix: remove unused teeWriter, fix human error return to ErrBare
liangshuo-1 Apr 9, 2026
e6c3cdc
test: add upgrade edge case and error path tests
liangshuo-1 Apr 9, 2026
a0c9725
refactor: update doctor hint to use lark-cli upgrade
liangshuo-1 Apr 9, 2026
a49cef8
style: fix gofmt formatting
liangshuo-1 Apr 9, 2026
f037279
fix: clear stale update notice after successful upgrade
liangshuo-1 Apr 9, 2026
92e820e
fix: suppress update notice via PendingNotice=nil to avoid race with …
liangshuo-1 Apr 9, 2026
f983ffe
refactor: rename upgrade to update, doctor shows both hints
liangshuo-1 Apr 9, 2026
27382b8
feat: improve update cmd — fix exit codes, add --check, version-pinne…
liangshuo-1 Apr 9, 2026
b874cec
fix: make --check output install-method-aware, add auto_update field
liangshuo-1 Apr 9, 2026
14defa9
fix: split install method and auto-update capability, fix changelogUR…
liangshuo-1 Apr 9, 2026
d31e911
feat: auto-update skills after CLI update via npx skills add
liangshuo-1 Apr 9, 2026
87a6b19
fix: include skills error detail in output, add skills update tests
liangshuo-1 Apr 9, 2026
5dfc547
fix: use only stderr for skills_detail to avoid stdout noise
liangshuo-1 Apr 9, 2026
58efec6
fix: Windows binary lock detection and ASCII symbol fallback for lega…
liangshuo-1 Apr 9, 2026
e7ab0d6
test: add Windows binary lock, symbol fallback, and permission hint t…
liangshuo-1 Apr 9, 2026
df625e3
fix: Windows --check guidance, PowerShell 5 compat (use ; not &&), ad…
liangshuo-1 Apr 9, 2026
e746f13
refactor: extract osWindows const, use numbered steps for Windows hin…
liangshuo-1 Apr 9, 2026
bba9cc4
feat: add selfupdate package with binary replacement and rollback
liangshuo-1 Apr 9, 2026
bb693a1
feat: integrate selfupdate — backup before upgrade, --rollback flag, …
liangshuo-1 Apr 9, 2026
cc71c5f
refactor: remove binary backup/rollback, implement Windows pre-rename…
liangshuo-1 Apr 9, 2026
b551c88
fix: robust Windows restore — remove partial files before restoring, …
liangshuo-1 Apr 9, 2026
e53c581
refactor: simplify update.go — fix manualReason bug, merge JSON/Human…
liangshuo-1 Apr 9, 2026
40c8560
test: add regression assertion for npm-missing manual reason message
liangshuo-1 Apr 9, 2026
75819d0
fix: suppress _notice at update command entry, remove unused npmPath …
liangshuo-1 Apr 9, 2026
8b67b7a
refactor: split selfupdate into build-tagged platform files (replace_…
liangshuo-1 Apr 10, 2026
167d8b6
refactor: use vfs.Remove/Rename/Stat instead of os.* in selfupdate (p…
liangshuo-1 Apr 10, 2026
1c2bd6b
refactor: extend vfs with EvalSymlinks/Executable, use Updater struct…
liangshuo-1 Apr 10, 2026
6fd467d
refactor: move detection/npm/skills logic into selfupdate.Updater, sl…
liangshuo-1 Apr 10, 2026
4201aa8
refactor: rename replace*.go to updater*.go to match Updater struct
liangshuo-1 Apr 10, 2026
f237695
fix: add .old recovery to run.js for Windows crash resilience, fix st…
liangshuo-1 Apr 10, 2026
9393366
feat: add post-update binary verification, semver prerelease support,…
liangshuo-1 Apr 10, 2026
874b4ca
fix: use rune-aware truncation to avoid splitting multi-byte characters
liangshuo-1 Apr 10, 2026
5b1ba30
style: fix gofmt alignment in update_test.go
liangshuo-1 Apr 10, 2026
5ddf2e3
fix: address review feedback — track backup state, fix npx -y positio…
liangshuo-1 Apr 10, 2026
30619ce
fix: use LookPath instead of os.Executable in VerifyBinary
liangshuo-1 Apr 10, 2026
f1c6d22
fix: add resolveExe fallback in VerifyBinary for absolute-path invoca…
liangshuo-1 Apr 10, 2026
e3105a3
fix: keep backupCreated on Updater struct, reference from both platforms
liangshuo-1 Apr 10, 2026
984f73f
fix: guard Windows restore against double-recovery, sync RunSkillsUpd…
liangshuo-1 Apr 10, 2026
7eea192
fix: verify current executable first, surface restore failures, fix n…
liangshuo-1 Apr 10, 2026
68e08e4
fix: pass -y to both npx and skills to prevent interactive prompts
liangshuo-1 Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ func checkCLIUpdate() []checkResult {
if update.IsNewer(latest, current) {
return []checkResult{warn("cli_update",
fmt.Sprintf("%s → %s available", current, latest),
"run: npm update -g @larksuite/cli")}
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
}
return []checkResult{pass("cli_update", latest+" (up to date)")}
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
Expand Down Expand Up @@ -118,6 +119,7 @@ func Execute() int {
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)

Expand Down
314 changes: 314 additions & 0 deletions cmd/update/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package cmdupdate

import (
"fmt"
"runtime"
"strings"

"github.com/spf13/cobra"

"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/update"
)

const (
repoURL = "https://github.com/larksuite/cli"
maxNpmOutput = 2000
osWindows = "windows"
)

// Overridable for testing.
var (
fetchLatest = func() (string, error) { return update.FetchLatest() }
currentVersion = func() string { return build.Version }
currentOS = runtime.GOOS
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
)

func isWindows() bool { return currentOS == osWindows }

func releaseURL(version string) string {
return repoURL + "/releases/tag/v" + strings.TrimPrefix(version, "v")
}

func changelogURL() string { return repoURL + "/blob/main/CHANGELOG.md" }

// --- Terminal symbols (ASCII fallback on Windows) ---

func symOK() string {
if isWindows() {
return "[OK]"
}
return "✓"
}

func symFail() string {
if isWindows() {
return "[FAIL]"
}
return "✗"
}

func symWarn() string {
if isWindows() {
return "[WARN]"
}
return "⚠"
}

func symArrow() string {
if isWindows() {
return "->"
}
return "→"
}

// --- Command ---

// UpdateOptions holds inputs for the update command.
type UpdateOptions struct {
Factory *cmdutil.Factory
JSON bool
Force bool
Check bool
}

// NewCmdUpdate creates the update command.
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
opts := &UpdateOptions{Factory: f}

cmd := &cobra.Command{
Use: "update",
Short: "Update lark-cli to the latest version",
Long: `Update lark-cli to the latest version.

Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL

Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`,
RunE: func(cmd *cobra.Command, args []string) error {
return updateRun(opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")

return cmd
}

func updateRun(opts *UpdateOptions) error {
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()

updater.CleanupStaleFiles()
output.PendingNotice = nil

// 1. Fetch latest version
latest, err := fetchLatest()
if err != nil {
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
}

// 2. Validate version format
if update.ParseVersion(latest) == nil {
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
}

// 3. Compare versions
if !opts.Force && !update.IsNewer(latest, cur) {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "already_up_to_date",
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
})
return nil
}
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
return nil
}

// 4. Detect installation method
detect := updater.DetectInstallMethod()

// 5. --check
if opts.Check {
return reportCheckResult(opts, io, cur, latest, detect.CanAutoUpdate())
}

// 6. Execute update
if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect)
}
return doNpmUpdate(opts, io, cur, latest, updater)
}

// --- Output helpers ---

func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
})
return output.ErrBare(exitCode)
}
return output.Errorf(exitCode, errType, "%s", msg)
}
Comment thread
liangshuo-1 marked this conversation as resolved.

func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "update_available",
"auto_update": canAutoUpdate,
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
"url": releaseURL(latest), "changelog": changelogURL(),
})
return nil
}
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if canAutoUpdate {
fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n")
} else {
fmt.Fprintf(io.ErrOut, "\nDownload the release above to update manually.\n")
}
return nil
}

func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult) error {
reason := detect.ManualReason()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": true, "previous_version": cur, "latest_version": latest,
"action": "manual_required",
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
"url": releaseURL(latest), "changelog": changelogURL(),
})
return nil
}
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
fmt.Fprintf(io.ErrOut, "\nAfter updating, also update skills:\n npx -y skills add larksuite/cli -g -y\n")
return nil
}

func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
}

if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
}

npmResult := updater.RunNpmInstall(latest)
if npmResult.Err != nil {
restore()
combined := npmResult.CombinedOutput()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined),
},
})
return output.ErrBare(output.ExitAPI)
}
if npmResult.Stdout.Len() > 0 {
fmt.Fprint(io.ErrOut, npmResult.Stdout.String())
}
if npmResult.Stderr.Len() > 0 {
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
}
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint)
}
return output.ErrBare(output.ExitAPI)
}

// Verify the new binary is functional before proceeding.
// If corrupt, restore the previous version from .old.
if err := updater.VerifyBinary(latest); err != nil {
restore()
msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false,
"error": map[string]interface{}{"type": "update_error", "message": msg, "hint": hint},
})
return output.ErrBare(output.ExitAPI)
}
fmt.Fprintf(io.ErrOut, "\n%s %s\n", symFail(), msg)
fmt.Fprintf(io.ErrOut, " %s\n", hint)
return output.ErrBare(output.ExitAPI)
}

// Skills update (best-effort).
skillsResult := updater.RunSkillsUpdate()

if opts.JSON {
result := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": latest,
"latest_version": latest, "action": "updated",
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
if skillsResult.Err != nil {
result["skills_warning"] = fmt.Sprintf("skills update failed: %s", skillsResult.Err)
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
result["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
}
}
output.PrintJson(io.Out, result)
return nil
}

fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
if skillsResult.Err != nil {
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %s\n", symWarn(), skillsResult.Err)
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, 500))
}
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
} else {
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
}
return nil
}

func permissionHint(npmOutput string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
}
return ""
}

func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
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))
}
Loading
Loading