// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT // Package selfupdate handles installation detection, npm-based updates, // skills updates, and platform-specific binary replacement for the CLI // self-update flow. package selfupdate import ( "bytes" "context" "fmt" "os/exec" "strings" "time" "github.com/larksuite/cli/internal/vfs" ) // execLookPath is the LookPath implementation used by VerifyBinary. // It defaults to the standard library exec.LookPath but is swapped in tests // via lookPathMock to provide controlled binary resolution. // // Tests that mutate execLookPath must not call t.Parallel(). var execLookPath = exec.LookPath // InstallMethod describes how the CLI was installed. type InstallMethod int const ( InstallNpm InstallMethod = iota InstallManual ) const ( NpmPackage = "@larksuite/cli" ) const ( npmInstallTimeout = 10 * time.Minute skillsUpdateTimeout = 2 * time.Minute verifyTimeout = 10 * time.Second ) // DetectResult holds installation detection results. type DetectResult struct { Method InstallMethod ResolvedPath string NpmAvailable bool } // CanAutoUpdate returns true if the CLI can update itself automatically. func (d DetectResult) CanAutoUpdate() bool { return d.Method == InstallNpm && d.NpmAvailable } // ManualReason returns a human-readable explanation of why auto-update is unavailable. func (d DetectResult) ManualReason() string { if d.Method == InstallNpm && !d.NpmAvailable { return "installed via npm, but npm is not available in PATH" } return "not installed via npm" } // NpmResult holds the result of an npm install or skills update execution. type NpmResult struct { Stdout bytes.Buffer Stderr bytes.Buffer Err error } // CombinedOutput returns stdout + stderr concatenated. func (r *NpmResult) CombinedOutput() string { return r.Stdout.String() + r.Stderr.String() } // Updater manages self-update operations. // Platform-specific methods (PrepareSelfReplace, CleanupStaleFiles) // are in updater_unix.go and updater_windows.go. // // Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride // / RestoreAvailableOverride for testing. type Updater struct { DetectOverride func() DetectResult NpmInstallOverride func(version string) *NpmResult SkillsCommandOverride func(args ...string) *NpmResult VerifyOverride func(expectedVersion string) error RestoreAvailableOverride func() bool // backupCreated is set to true by PrepareSelfReplace (Windows) when the // running binary is successfully renamed to .old. Used by // CanRestorePreviousVersion to report whether rollback is possible. backupCreated bool } // New creates an Updater with default (real) behavior. func New() *Updater { return &Updater{} } // DetectInstallMethod determines how the CLI was installed and whether // npm is available for auto-update. func (u *Updater) DetectInstallMethod() DetectResult { if u.DetectOverride != nil { return u.DetectOverride() } exe, err := vfs.Executable() if err != nil { return DetectResult{Method: InstallManual} } resolved, err := vfs.EvalSymlinks(exe) if err != nil { return DetectResult{Method: InstallManual, ResolvedPath: exe} } method := InstallManual if strings.Contains(resolved, "node_modules") { method = InstallNpm } npmAvailable := false if method == InstallNpm { if _, err := exec.LookPath("npm"); err == nil { npmAvailable = true } } return DetectResult{ Method: method, ResolvedPath: resolved, NpmAvailable: npmAvailable, } } // RunNpmInstall executes npm install -g @larksuite/cli@. func (u *Updater) RunNpmInstall(version string) *NpmResult { if u.NpmInstallOverride != nil { return u.NpmInstallOverride(version) } r := &NpmResult{} npmPath, err := exec.LookPath("npm") if err != nil { r.Err = fmt.Errorf("npm not found in PATH: %w", err) return r } ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout) defer cancel() cmd := exec.CommandContext(ctx, npmPath, "install", "-g", NpmPackage+"@"+version) cmd.Stdout = &r.Stdout cmd.Stderr = &r.Stderr r.Err = cmd.Run() if ctx.Err() == context.DeadlineExceeded { r.Err = fmt.Errorf("npm install timed out after %s", npmInstallTimeout) } return r } func (u *Updater) ListOfficialSkills() *NpmResult { r := u.runSkillsListOfficial("https://open.feishu.cn") if r.Err != nil { r = u.runSkillsListOfficial("larksuite/cli") } return r } func (u *Updater) ListGlobalSkills() *NpmResult { return u.runSkillsListGlobal() } func (u *Updater) InstallSkill(nameList []string) *NpmResult { r := u.runSkillsInstall("https://open.feishu.cn", nameList) if r.Err != nil { r = u.runSkillsInstall("larksuite/cli", nameList) } return r } func (u *Updater) InstallAllSkills() *NpmResult { r := u.runSkillsAdd("https://open.feishu.cn") if r.Err != nil { r = u.runSkillsAdd("larksuite/cli") } return r } func (u *Updater) runSkillsAdd(source string) *NpmResult { return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y") } func (u *Updater) runSkillsListOfficial(source string) *NpmResult { return u.runSkillsCommand("-y", "skills", "add", source, "--list") } func (u *Updater) runSkillsListGlobal() *NpmResult { return u.runSkillsCommand("-y", "skills", "ls", "-g") } func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult { args := []string{"-y", "skills", "add", source, "-s"} args = append(args, nameList...) args = append(args, "-g", "-y") return u.runSkillsCommand(args...) } func (u *Updater) runSkillsCommand(args ...string) *NpmResult { if u.SkillsCommandOverride != nil { return u.SkillsCommandOverride(args...) } r := &NpmResult{} npxPath, err := exec.LookPath("npx") if err != nil { r.Err = fmt.Errorf("npx not found in PATH: %w", err) return r } ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout) defer cancel() cmd := exec.CommandContext(ctx, npxPath, args...) cmd.Stdout = &r.Stdout cmd.Stderr = &r.Stderr r.Err = cmd.Run() if ctx.Err() == context.DeadlineExceeded { r.Err = fmt.Errorf("skills update timed out after %s", skillsUpdateTimeout) } return r } // VerifyBinary checks that the installed binary reports the expected version // by running "lark-cli --version" and comparing the version token exactly. // Output format is "lark-cli version X.Y.Z"; the last field is extracted and // compared against expectedVersion (both stripped of any "v" prefix). func (u *Updater) VerifyBinary(expectedVersion string) error { if u.VerifyOverride != nil { return u.VerifyOverride(expectedVersion) } // Prefer PATH resolution so npm global bin symlinks pick up the newly // installed binary (#836). If `lark-cli` is not on PATH (e.g. the user // invoked this process by absolute path), fall back to the running // executable — same as the pre-#836 secondary resolution path. exe, err := execLookPath("lark-cli") if err != nil { exe, err = vfs.Executable() if err != nil { return fmt.Errorf("cannot locate binary: %w", err) } } ctx, cancel := context.WithTimeout(context.Background(), verifyTimeout) defer cancel() out, err := exec.CommandContext(ctx, exe, "--version").Output() if ctx.Err() == context.DeadlineExceeded { return fmt.Errorf("binary verification timed out after %s", verifyTimeout) } if err != nil { return fmt.Errorf("binary not executable: %w", err) } fields := strings.Fields(strings.TrimSpace(string(out))) if len(fields) == 0 { return fmt.Errorf("empty version output") } actual := strings.TrimPrefix(fields[len(fields)-1], "v") expected := strings.TrimPrefix(expectedVersion, "v") if actual != expected { return fmt.Errorf("expected version %s, got %q", expectedVersion, actual) } return nil } // Truncate returns the last maxLen runes of s. func Truncate(s string, maxLen int) string { if maxLen <= 0 { return "" } r := []rune(s) if len(r) <= maxLen { return s } return string(r[len(r)-maxLen:]) } // resolveExe returns the resolved path of the current running binary. func (u *Updater) resolveExe() (string, error) { exe, err := vfs.Executable() if err != nil { return "", err } return vfs.EvalSymlinks(exe) }