diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 67d959894..7df188b4b 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -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)")} } diff --git a/cmd/root.go b/cmd/root.go index 8996dd8a6..dca93f7cb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" @@ -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) diff --git a/cmd/update/update.go b/cmd/update/update.go new file mode 100644 index 000000000..182ef57db --- /dev/null +++ b/cmd/update/update.go @@ -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@ + - 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) +} + +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)) +} diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go new file mode 100644 index 000000000..41abe2961 --- /dev/null +++ b/cmd/update/update_test.go @@ -0,0 +1,851 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdupdate + +import ( + "bytes" + "errors" + "fmt" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/selfupdate" +) + +// newTestFactory creates a test factory with minimal config. +func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + return f, stdout, stderr +} + +// mockDetect sets up newUpdater to return an Updater with the given DetectResult. +// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later. +func mockDetect(t *testing.T, result selfupdate.DetectResult) { + t.Helper() + origNew := newUpdater + newUpdater = func() *selfupdate.Updater { + u := selfupdate.New() + u.DetectOverride = func() selfupdate.DetectResult { return result } + return u + } + t.Cleanup(func() { newUpdater = origNew }) +} + +// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once. +func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, + npmFn func(string) *selfupdate.NpmResult, + skillsFn func() *selfupdate.NpmResult) { + t.Helper() + origNew := newUpdater + newUpdater = func() *selfupdate.Updater { + u := selfupdate.New() + u.DetectOverride = func() selfupdate.DetectResult { return result } + u.NpmInstallOverride = npmFn + u.SkillsUpdateOverride = skillsFn + u.VerifyOverride = func(string) error { return nil } + return u + } + t.Cleanup(func() { newUpdater = origNew }) +} + +func TestUpdateAlreadyUpToDate_JSON(t *testing.T) { + f, stdout, _ := newTestFactory(t) + + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "1.0.0", nil } + defer func() { fetchLatest = origFetch }() + + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, `"action": "already_up_to_date"`) { + t.Errorf("expected already_up_to_date in JSON output, got: %s", out) + } + if !strings.Contains(out, `"ok": true`) { + t.Errorf("expected ok:true in JSON output, got: %s", out) + } +} + +func TestUpdateAlreadyUpToDate_Human(t *testing.T) { + f, _, stderr := newTestFactory(t) + + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "1.0.0", nil } + defer func() { fetchLatest = origFetch }() + + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stderr.String() + if !strings.Contains(out, "already up to date") { + t.Errorf("expected 'already up to date' in stderr, got: %s", out) + } +} + +func TestUpdateManual_JSON(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"action": "manual_required"`) { + t.Errorf("expected manual_required in output, got: %s", out) + } + if !strings.Contains(out, "not installed via npm") { + t.Errorf("expected accurate reason in output, got: %s", out) + } + if !strings.Contains(out, "releases/tag/v2.0.0") { + t.Errorf("expected version-pinned URL in output, got: %s", out) + } +} + +func TestUpdateManual_Human(t *testing.T) { + f, _, stderr := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stderr.String() + if !strings.Contains(out, "not installed via npm") { + t.Errorf("expected 'not installed via npm' in stderr, got: %s", out) + } + if !strings.Contains(out, "releases/tag/v2.0.0") { + t.Errorf("expected version-pinned URL in stderr, got: %s", out) + } +} + +func TestUpdateNpm_JSON(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + mockDetectAndNpm(t, + selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, + func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + ) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"action": "updated"`) { + t.Errorf("expected updated in output, got: %s", out) + } +} + +func TestUpdateNpm_Human(t *testing.T) { + f, _, stderr := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + mockDetectAndNpm(t, + selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, + func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + ) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stderr.String() + if !strings.Contains(out, "Successfully updated") { + t.Errorf("expected success message in stderr, got: %s", out) + } +} + +func TestUpdateForce_JSON(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--force", "--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "1.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + mockDetectAndNpm(t, + selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, + func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + ) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"action": "updated"`) { + t.Errorf("expected updated in JSON output, got: %s", out) + } +} + +func TestUpdateFetchError_JSON(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "", errors.New("network timeout") } + defer func() { fetchLatest = origFetch }() + + err := cmd.Execute() + // cobra silences errors when RunE returns; we just check stdout + _ = err + out := stdout.String() + if !strings.Contains(out, `"ok": false`) { + t.Errorf("expected ok:false in JSON output, got: %s", out) + } + if !strings.Contains(out, "network timeout") { + t.Errorf("expected 'network timeout' in JSON output, got: %s", out) + } +} + +func TestUpdateFetchError_Human(t *testing.T) { + f, _, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "", errors.New("network timeout") } + defer func() { fetchLatest = origFetch }() + + // Suppress cobra's default error printing. + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected non-nil error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitNetwork { + t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code) + } +} + +func TestUpdateInvalidVersion_JSON(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "not-a-version", nil } + defer func() { fetchLatest = origFetch }() + + _ = cmd.Execute() + out := stdout.String() + if !strings.Contains(out, "invalid version") { + t.Errorf("expected 'invalid version' in JSON output, got: %s", out) + } +} + +func TestUpdateDevVersion_JSON(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "1.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "DEV" } + defer func() { currentVersion = origVersion }() + mockDetectAndNpm(t, + selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, + func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + ) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"action": "updated"`) { + t.Errorf("expected updated in JSON output, got: %s", out) + } +} + +func TestUpdateNpmFail_JSON(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + origNew := newUpdater + newUpdater = func() *selfupdate.Updater { + u := selfupdate.New() + u.DetectOverride = func() selfupdate.DetectResult { + return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true} + } + u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + fmt.Fprint(&r.Stderr, "EACCES: permission denied") + r.Err = errors.New("npm install failed") + return r + } + return u + } + defer func() { newUpdater = origNew }() + + _ = cmd.Execute() + out := stdout.String() + if !strings.Contains(out, "permission denied") { + t.Errorf("expected 'permission denied' in JSON output, got: %s", out) + } + if !strings.Contains(out, `"hint"`) { + t.Errorf("expected 'hint' field in JSON output, got: %s", out) + } +} + +func TestUpdateNpmFail_Human(t *testing.T) { + f, _, stderr := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + origNew := newUpdater + newUpdater = func() *selfupdate.Updater { + u := selfupdate.New() + u.DetectOverride = func() selfupdate.DetectResult { + return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true} + } + u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + fmt.Fprint(&r.Stderr, "EACCES: permission denied") + r.Err = errors.New("npm install failed") + return r + } + return u + } + defer func() { newUpdater = origNew }() + + cmd.SilenceErrors = true + cmd.SilenceUsage = true + _ = cmd.Execute() + out := stderr.String() + if !strings.Contains(out, "Update failed") { + t.Errorf("expected 'Update failed' in stderr, got: %s", out) + } + if !strings.Contains(out, "Permission denied") { + t.Errorf("expected permission hint in stderr, got: %s", out) + } +} + +func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + origNew := newUpdater + newUpdater = func() *selfupdate.Updater { + u := selfupdate.New() + u.DetectOverride = func() selfupdate.DetectResult { + return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true} + } + u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } + u.VerifyOverride = func(string) error { return errors.New("bad binary") } + u.RestoreAvailableOverride = func() bool { return false } + u.SkillsUpdateOverride = func() *selfupdate.NpmResult { + t.Fatal("skills update should not run when binary verification fails") + return nil + } + return u + } + defer func() { newUpdater = origNew }() + + err := cmd.Execute() + if err == nil { + t.Fatal("expected verification failure") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitAPI { + t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code) + } + + out := stdout.String() + if !strings.Contains(out, "automatic rollback is unavailable") { + t.Errorf("expected unavailable rollback hint, got: %s", out) + } + if strings.Contains(out, "previous version has been restored") { + t.Errorf("should not claim restore when no backup is available, got: %s", out) + } + if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") { + t.Errorf("expected manual reinstall command in hint, got: %s", out) + } +} + +func TestUpdateCheck_JSON_Npm(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json", "--check"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"action": "update_available"`) { + t.Errorf("expected update_available action, got: %s", out) + } + if !strings.Contains(out, `"auto_update": true`) { + t.Errorf("expected auto_update:true for npm, got: %s", out) + } + if !strings.Contains(out, "releases/tag/v2.0.0") { + t.Errorf("expected version-pinned release URL, got: %s", out) + } + if !strings.Contains(out, "CHANGELOG") { + t.Errorf("expected changelog URL, got: %s", out) + } +} + +func TestUpdateCheck_Human_Npm(t *testing.T) { + f, _, stderr := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--check"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stderr.String() + if !strings.Contains(out, "Update available") { + t.Errorf("expected 'Update available' in stderr, got: %s", out) + } + if !strings.Contains(out, "lark-cli update") { + t.Errorf("expected 'lark-cli update' instruction for npm, got: %s", out) + } +} + +func TestUpdateCheck_Human_Manual(t *testing.T) { + f, _, stderr := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--check"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stderr.String() + if !strings.Contains(out, "Update available") { + t.Errorf("expected 'Update available' in stderr, got: %s", out) + } + if !strings.Contains(out, "manually") { + t.Errorf("expected manual download instruction for non-npm, got: %s", out) + } + if strings.Contains(out, "lark-cli update` to install") { + t.Errorf("should NOT suggest 'lark-cli update' for manual install, got: %s", out) + } +} + +func TestUpdateNpmNotFound_FallsBackToManual(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + // npm detected (node_modules in path) but npm binary not available + mockDetect(t, selfupdate.DetectResult{ + Method: selfupdate.InstallNpm, + ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", + NpmAvailable: false, + }) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"action": "manual_required"`) { + t.Errorf("expected manual_required when npm not found, got: %s", out) + } + // Must say "npm is not available", not generic "not installed via npm" + if !strings.Contains(out, "npm is not available") { + t.Errorf("expected 'npm is not available' reason when npm detected but missing, got: %s", out) + } +} + +func TestReleaseURL(t *testing.T) { + got := releaseURL("2.0.0") + if got != "https://github.com/larksuite/cli/releases/tag/v2.0.0" { + t.Errorf("expected version-pinned URL, got: %s", got) + } + got2 := releaseURL("v1.5.0") + if got2 != "https://github.com/larksuite/cli/releases/tag/v1.5.0" { + t.Errorf("expected no double v prefix, got: %s", got2) + } +} + +func TestPermissionHint(t *testing.T) { + origOS := currentOS + defer func() { currentOS = origOS }() + + // Linux: EACCES should produce a hint with npm prefix guidance. + currentOS = "linux" + hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'") + if !strings.Contains(hint, "npm global prefix") { + t.Errorf("expected npm prefix hint on linux, got: %s", hint) + } + if strings.Contains(hint, "sudo npm install -g") { + t.Errorf("should not suggest raw sudo npm install, got: %s", hint) + } + + // Windows: EACCES hint is suppressed (no EACCES on Windows). + currentOS = "windows" + hint = permissionHint("EACCES: permission denied") + if hint != "" { + t.Errorf("expected empty hint on Windows, got: %s", hint) + } + + // Non-EACCES error: always empty. + currentOS = "linux" + if got := permissionHint("some other error"); got != "" { + t.Errorf("expected empty hint for non-EACCES, got: %s", got) + } +} + +func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) { + // With the rename trick, Windows npm installs can now auto-update. + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + origOS := currentOS + currentOS = osWindows + defer func() { currentOS = origOS }() + mockDetectAndNpm(t, + selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true}, + func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + ) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"action": "updated"`) { + t.Errorf("expected updated on Windows with rename trick, got: %s", out) + } +} + +func TestUpdateWindows_Check_JSON(t *testing.T) { + // --check on Windows npm should report auto_update: true (rename trick available). + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json", "--check"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + origOS := currentOS + currentOS = osWindows + defer func() { currentOS = origOS }() + mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"auto_update": true`) { + t.Errorf("expected auto_update:true on Windows (rename trick), got: %s", out) + } +} + +func TestUpdateWindows_Symbols(t *testing.T) { + origOS := currentOS + defer func() { currentOS = origOS }() + + currentOS = "windows" + if symOK() != "[OK]" { + t.Errorf("expected [OK] on Windows, got: %s", symOK()) + } + if symFail() != "[FAIL]" { + t.Errorf("expected [FAIL] on Windows, got: %s", symFail()) + } + if symWarn() != "[WARN]" { + t.Errorf("expected [WARN] on Windows, got: %s", symWarn()) + } + if symArrow() != "->" { + t.Errorf("expected -> on Windows, got: %s", symArrow()) + } + + currentOS = "darwin" + if symOK() != "\u2713" { + t.Errorf("expected \u2713 on darwin, got: %s", symOK()) + } + if symArrow() != "\u2192" { + t.Errorf("expected \u2192 on darwin, got: %s", symArrow()) + } +} + +func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + mockDetectAndNpm(t, + selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, + func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, + ) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + // Should NOT have skills_warning when skills succeed + if strings.Contains(out, "skills_warning") { + t.Errorf("expected no skills_warning on success, got: %s", out) + } +} + +func TestUpdateNpm_SkillsFail_JSON(t *testing.T) { + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + origNew := newUpdater + newUpdater = func() *selfupdate.Updater { + u := selfupdate.New() + u.DetectOverride = func() selfupdate.DetectResult { + return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true} + } + u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } + u.VerifyOverride = func(string) error { return nil } + // Skills update fails + u.SkillsUpdateOverride = func() *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + r.Stderr.WriteString("npx: command not found") + r.Err = fmt.Errorf("exit status 127") + return r + } + return u + } + defer func() { newUpdater = origNew }() + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + // CLI update should still succeed (ok:true) + if !strings.Contains(out, `"ok": true`) { + t.Errorf("expected ok:true despite skills failure, got: %s", out) + } + if !strings.Contains(out, `"action": "updated"`) { + t.Errorf("expected action:updated despite skills failure, got: %s", out) + } + // Should have skills_warning with detail + if !strings.Contains(out, "skills_warning") { + t.Errorf("expected skills_warning in output, got: %s", out) + } + if !strings.Contains(out, "skills_detail") { + t.Errorf("expected skills_detail in output, got: %s", out) + } +} + +func TestUpdateNpm_SkillsFail_Human(t *testing.T) { + f, _, stderr := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "2.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + origNew := newUpdater + newUpdater = func() *selfupdate.Updater { + u := selfupdate.New() + u.DetectOverride = func() selfupdate.DetectResult { + return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true} + } + u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } + u.VerifyOverride = func(string) error { return nil } + u.SkillsUpdateOverride = func() *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + r.Stderr.WriteString("npx: command not found") + r.Err = fmt.Errorf("exit status 127") + return r + } + return u + } + defer func() { newUpdater = origNew }() + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stderr.String() + // CLI update should still show success + if !strings.Contains(out, "Successfully updated") { + t.Errorf("expected CLI success message, got: %s", out) + } + // Skills warning should be shown + if !strings.Contains(out, "Skills update failed") { + t.Errorf("expected skills failure warning, got: %s", out) + } + if !strings.Contains(out, "npx -y skills add") { + t.Errorf("expected manual skills command hint, got: %s", out) + } +} + +func TestTruncate(t *testing.T) { + long := strings.Repeat("x", 3000) + got := selfupdate.Truncate(long, 2000) + if len(got) != 2000 { + t.Errorf("expected truncated length 2000, got %d", len(got)) + } + + short := "hello" + got2 := selfupdate.Truncate(short, 2000) + if got2 != "hello" { + t.Errorf("expected 'hello', got %q", got2) + } +} diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go new file mode 100644 index 000000000..0d8502bbf --- /dev/null +++ b/internal/selfupdate/updater.go @@ -0,0 +1,231 @@ +// 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" +) + +// 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 / SkillsUpdateOverride / VerifyOverride +// / RestoreAvailableOverride for testing. +type Updater struct { + DetectOverride func() DetectResult + NpmInstallOverride func(version string) *NpmResult + SkillsUpdateOverride func() *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 +} + +// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y. +func (u *Updater) RunSkillsUpdate() *NpmResult { + if u.SkillsUpdateOverride != nil { + return u.SkillsUpdateOverride() + } + 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, "-y", "skills", "add", "larksuite/cli", "-g", "-y") + 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 the current executable path (what the user actually launched). + // Use Executable() directly without EvalSymlinks — after npm install the + // symlink target may have changed, but the path itself is still valid for + // execution. Fall back to LookPath only if Executable() fails entirely. + exe, err := vfs.Executable() + if err != nil { + exe, err = exec.LookPath("lark-cli") + 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) +} diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go new file mode 100644 index 000000000..a46b51cb0 --- /dev/null +++ b/internal/selfupdate/updater_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package selfupdate + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/larksuite/cli/internal/vfs" +) + +type executableTestFS struct { + vfs.OsFs + exe string +} + +func (f executableTestFS) Executable() (string, error) { return f.exe, nil } + +func TestResolveExe(t *testing.T) { + u := New() + p, err := u.resolveExe() + if err != nil { + t.Fatalf("resolveExe() error: %v", err) + } + if !filepath.IsAbs(p) { + t.Errorf("expected absolute path, got: %s", p) + } +} + +func TestPrepareSelfReplace_ReturnsNoError(t *testing.T) { + u := New() + restore, err := u.PrepareSelfReplace() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + restore() +} + +func TestCleanupStaleFiles_NoPanic(t *testing.T) { + u := New() + u.CleanupStaleFiles() +} + +func TestVerifyBinaryChecksVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell script") + } + + dir := t.TempDir() + exe := filepath.Join(dir, "lark-cli") + // Script prints version string matching real CLI format when --version is passed. + script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.0.0\"; exit 0; fi\nexit 12\n" + if err := os.WriteFile(exe, []byte(script), 0755); err != nil { + t.Fatalf("write test binary: %v", err) + } + + // Mock vfs.Executable to return our test script, matching VerifyBinary's + // primary lookup path. Also prepend to PATH for the LookPath fallback. + origFS := vfs.DefaultFS + vfs.DefaultFS = executableTestFS{OsFs: vfs.OsFs{}, exe: exe} + t.Cleanup(func() { vfs.DefaultFS = origFS }) + + origPath := os.Getenv("PATH") + t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath) + + // Matching version → success. + if err := New().VerifyBinary("2.0.0"); err != nil { + t.Fatalf("VerifyBinary(matching) error = %v, want nil", err) + } + + // Mismatched version → error. + if err := New().VerifyBinary("3.0.0"); err == nil { + t.Fatal("VerifyBinary(mismatched) expected error, got nil") + } + + // Substring of actual version must not match (e.g. "0.0" is in "2.0.0"). + if err := New().VerifyBinary("0.0"); err == nil { + t.Fatal("VerifyBinary(substring) expected error, got nil") + } + + // Version that is a prefix of actual must not match (e.g. "2.0.0" in "12.0.0"). + // Binary reports "2.0.0", asking for "12.0.0" must fail. + if err := New().VerifyBinary("12.0.0"); err == nil { + t.Fatal("VerifyBinary(prefix-mismatch) expected error, got nil") + } +} diff --git a/internal/selfupdate/updater_unix.go b/internal/selfupdate/updater_unix.go new file mode 100644 index 000000000..a9ea802cc --- /dev/null +++ b/internal/selfupdate/updater_unix.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package selfupdate + +// PrepareSelfReplace is a no-op on Unix. +// Unix allows overwriting a running executable via inode semantics. +func (u *Updater) PrepareSelfReplace() (restore func(), err error) { + return func() {}, nil +} + +// CleanupStaleFiles is a no-op on Unix (no .old files are created). +func (u *Updater) CleanupStaleFiles() {} + +// CanRestorePreviousVersion reports whether PrepareSelfReplace created a +// restorable backup for the current update attempt. +func (u *Updater) CanRestorePreviousVersion() bool { + if u.RestoreAvailableOverride != nil { + return u.RestoreAvailableOverride() + } + return u.backupCreated +} diff --git a/internal/selfupdate/updater_windows.go b/internal/selfupdate/updater_windows.go new file mode 100644 index 000000000..1a7db20e2 --- /dev/null +++ b/internal/selfupdate/updater_windows.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build windows + +package selfupdate + +import ( + "fmt" + + "github.com/larksuite/cli/internal/vfs" +) + +// PrepareSelfReplace renames the running .exe to .old so that npm's +// postinstall script can write the new binary without hitting EBUSY. +// Returns a restore function that undoes the rename on failure. +func (u *Updater) PrepareSelfReplace() (restore func(), err error) { + noop := func() {} + + exe, err := u.resolveExe() + if err != nil { + return noop, nil // best-effort; don't block update + } + + oldPath := exe + ".old" + + // Clean up stale .old from a previous upgrade. + vfs.Remove(oldPath) + + // Rename running.exe → running.exe.old (Windows allows rename of locked files). + if err := vfs.Rename(exe, oldPath); err != nil { + return noop, fmt.Errorf("cannot rename binary for update: %w", err) + } + u.backupCreated = true + + // Restore: move .old back to the original path. + // Guard with Stat: run.js may have already recovered .old on its own + // during VerifyBinary; if .old is gone, skip to avoid deleting the + // only working binary. + // On any failure, clear backupCreated so CanRestorePreviousVersion + // reports the real outcome instead of claiming success. + restore = func() { + if _, err := vfs.Stat(oldPath); err != nil { + u.backupCreated = false + return + } + vfs.Remove(exe) + if err := vfs.Rename(oldPath, exe); err != nil { + u.backupCreated = false + } + } + + return restore, nil +} + +// CleanupStaleFiles removes leftover .old files from previous upgrades. +// If the original binary is missing but .old exists (crash mid-update), +// it restores the .old to recover the installation. +func (u *Updater) CleanupStaleFiles() { + exe, err := u.resolveExe() + if err != nil { + return + } + oldPath := exe + ".old" + + if _, err := vfs.Stat(oldPath); err != nil { + return // no .old file + } + + if _, err := vfs.Stat(exe); err != nil { + // Original missing, .old exists — restore to recover. + vfs.Rename(oldPath, exe) + return + } + + // Both exist — .old is stale, clean up. + vfs.Remove(oldPath) +} + +// CanRestorePreviousVersion reports whether PrepareSelfReplace created a +// restorable backup for the current update attempt. +func (u *Updater) CanRestorePreviousVersion() bool { + if u.RestoreAvailableOverride != nil { + return u.RestoreAvailableOverride() + } + return u.backupCreated +} diff --git a/internal/update/update.go b/internal/update/update.go index fbf980d6c..c87bb59fc 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -218,8 +218,8 @@ func fetchLatestVersion() (string, error) { // is considered newer — an unparseable local version is assumed outdated. // When a cannot be parsed, returns false (can't confirm it's newer). func IsNewer(a, b string) bool { - ap := ParseVersion(a) - bp := ParseVersion(b) + ap := parseVersionDetail(a) + bp := parseVersionDetail(b) if ap == nil { return false // can't confirm remote is newer } @@ -227,28 +227,59 @@ func IsNewer(a, b string) bool { return true // local version unparseable → assume outdated } for i := 0; i < 3; i++ { - if ap[i] > bp[i] { + if ap.core[i] > bp.core[i] { return true } - if ap[i] < bp[i] { + if ap.core[i] < bp.core[i] { return false } } - return false + return comparePrerelease(ap.prerelease, bp.prerelease) > 0 } // ParseVersion parses "X.Y.Z" (with optional "v" prefix and pre-release suffix) // into [major, minor, patch]. Returns nil on invalid input. func ParseVersion(v string) []int { + parsed := parseVersionDetail(v) + if parsed == nil { + return nil + } + return []int{parsed.core[0], parsed.core[1], parsed.core[2]} +} + +type parsedVersion struct { + core [3]int + prerelease string +} + +// validPrerelease matches semver pre-release identifiers (dot-separated). +// Each identifier is either: "0", a non-zero-leading numeric, or alphanumeric with at least one letter/hyphen. +// Rejects empty identifiers ("1.0.0-"), leading-zero numerics ("1.0.0-01"), etc. +var validPrerelease = regexp.MustCompile( + `^(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)` + + `(?:\.(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*$`) + +func parseVersionDetail(v string) *parsedVersion { v = strings.TrimPrefix(v, "v") + if idx := strings.Index(v, "+"); idx >= 0 { + v = v[:idx] + } + prerelease := "" + if idx := strings.Index(v, "-"); idx >= 0 { + prerelease = v[idx+1:] + v = v[:idx] + if prerelease == "" || !validPrerelease.MatchString(prerelease) { + return nil + } + } parts := strings.SplitN(v, ".", 3) if len(parts) != 3 { return nil } - nums := make([]int, 3) + var nums [3]int for i, p := range parts { - if idx := strings.IndexAny(p, "-+"); idx >= 0 { - p = p[:idx] + if len(p) > 1 && p[0] == '0' { + return nil // leading zero in core part (e.g. "01.0.0") } n, err := strconv.Atoi(p) if err != nil { @@ -256,5 +287,56 @@ func ParseVersion(v string) []int { } nums[i] = n } - return nums + return &parsedVersion{core: nums, prerelease: prerelease} +} + +func comparePrerelease(a, b string) int { + if a == "" && b == "" { + return 0 + } + if a == "" { + return 1 + } + if b == "" { + return -1 + } + ap := strings.Split(a, ".") + bp := strings.Split(b, ".") + for i := 0; i < len(ap) && i < len(bp); i++ { + cmp := comparePrereleaseIdentifier(ap[i], bp[i]) + if cmp != 0 { + return cmp + } + } + switch { + case len(ap) > len(bp): + return 1 + case len(ap) < len(bp): + return -1 + default: + return 0 + } +} + +func comparePrereleaseIdentifier(a, b string) int { + an, aErr := strconv.Atoi(a) + bn, bErr := strconv.Atoi(b) + aNumeric := aErr == nil + bNumeric := bErr == nil + switch { + case aNumeric && bNumeric: + if an > bn { + return 1 + } + if an < bn { + return -1 + } + return 0 + case aNumeric: + return -1 + case bNumeric: + return 1 + default: + return strings.Compare(a, b) + } } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index a56a99676..5ff356225 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -56,6 +56,9 @@ func TestIsNewer(t *testing.T) { {"1.0.0", "9b933f1", true}, // bare commit hash → assume outdated {"", "1.0.0", false}, // empty remote → false {"1.1.0", "v1.0.0-12-g9b933f1-dirty", true}, // git describe: 1.1.0 > 1.0.0 + {"1.0.0", "1.0.0-rc.1", true}, // stable release > prerelease + {"1.0.0-rc.2", "1.0.0-rc.1", true}, // prerelease identifiers are ordered + {"1.0.0-rc.1", "1.0.0", false}, // prerelease < stable release } for _, tt := range tests { got := IsNewer(tt.a, tt.b) @@ -74,6 +77,16 @@ func TestParseVersion(t *testing.T) { {"v1.2.3", []int{1, 2, 3}}, {"0.0.1", []int{0, 0, 1}}, {"1.0.0-beta.1", []int{1, 0, 0}}, + {"1.0.0-rc.1", []int{1, 0, 0}}, + {"1.0.0-0", []int{1, 0, 0}}, + {"1.0.0+build.123", []int{1, 0, 0}}, + {"1.0.0-beta.1+build", []int{1, 0, 0}}, + {"1.0.0-", nil}, // empty pre-release + {"1.0.0-01", nil}, // leading zero in numeric pre-release + {"1.0.0-beta..1", nil}, // empty identifier between dots + {"01.0.0", nil}, // leading zero in major + {"1.00.0", nil}, // leading zero in minor + {"1.0.00", nil}, // leading zero in patch {"DEV", nil}, {"", nil}, {"1.2", nil}, diff --git a/internal/vfs/default.go b/internal/vfs/default.go index 78b4d91a7..4b376f9f1 100644 --- a/internal/vfs/default.go +++ b/internal/vfs/default.go @@ -31,3 +31,5 @@ func MkdirAll(path string, perm fs.FileMode) error { return DefaultFS.MkdirA func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDir(name) } func Remove(name string) error { return DefaultFS.Remove(name) } func Rename(oldpath, newpath string) error { return DefaultFS.Rename(oldpath, newpath) } +func EvalSymlinks(path string) (string, error) { return DefaultFS.EvalSymlinks(path) } +func Executable() (string, error) { return DefaultFS.Executable() } diff --git a/internal/vfs/fs.go b/internal/vfs/fs.go index acaea3e18..70298d5a2 100644 --- a/internal/vfs/fs.go +++ b/internal/vfs/fs.go @@ -29,4 +29,8 @@ type FS interface { ReadDir(name string) ([]os.DirEntry, error) Remove(name string) error Rename(oldpath, newpath string) error + + // Path resolution + EvalSymlinks(path string) (string, error) + Executable() (string, error) } diff --git a/internal/vfs/osfs.go b/internal/vfs/osfs.go index 081801aed..0722c6e62 100644 --- a/internal/vfs/osfs.go +++ b/internal/vfs/osfs.go @@ -6,6 +6,7 @@ package vfs import ( "io/fs" "os" + "path/filepath" ) // OsFs delegates every method to the os standard library. @@ -33,3 +34,7 @@ func (OsFs) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(p func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) } func (OsFs) Remove(name string) error { return os.Remove(name) } func (OsFs) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) } + +// Path resolution +func (OsFs) EvalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } +func (OsFs) Executable() (string, error) { return os.Executable() } diff --git a/scripts/run.js b/scripts/run.js index 8e6c9ee53..c61e71380 100755 --- a/scripts/run.js +++ b/scripts/run.js @@ -9,6 +9,38 @@ const path = require("path"); const ext = process.platform === "win32" ? ".exe" : ""; const bin = path.join(__dirname, "..", "bin", "lark-cli" + ext); +// On Windows, a crashed self-update may have left the binary renamed to .old. +// Recover it before proceeding so the CLI remains functional. +const oldBin = bin + ".old"; +function restoreOldBinary() { + try { + if (fs.existsSync(bin)) { + fs.rmSync(bin, { force: true }); + } + fs.renameSync(oldBin, bin); + return true; + } catch (_) { + return false; + } +} + +if (process.platform === "win32" && fs.existsSync(oldBin)) { + if (!fs.existsSync(bin)) { + restoreOldBinary(); + } else { + try { + execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 10000 }); + try { + fs.rmSync(oldBin, { force: true }); + } catch (_) { + // Best-effort cleanup; keep running the healthy binary. + } + } catch (_) { + restoreOldBinary(); + } + } +} + if (!fs.existsSync(bin)) { console.error( `Error: lark-cli binary not found at ${bin}\n\n` +