-
Notifications
You must be signed in to change notification settings - Fork 605
feat: add update command with self-update, verification, and rollback #391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 7cab417
feat: implement upgrade detection and npm execution
liangshuo-1 8753432
fix: remove unused teeWriter, fix human error return to ErrBare
liangshuo-1 e6c3cdc
test: add upgrade edge case and error path tests
liangshuo-1 a0c9725
refactor: update doctor hint to use lark-cli upgrade
liangshuo-1 a49cef8
style: fix gofmt formatting
liangshuo-1 f037279
fix: clear stale update notice after successful upgrade
liangshuo-1 92e820e
fix: suppress update notice via PendingNotice=nil to avoid race with …
liangshuo-1 f983ffe
refactor: rename upgrade to update, doctor shows both hints
liangshuo-1 27382b8
feat: improve update cmd — fix exit codes, add --check, version-pinne…
liangshuo-1 b874cec
fix: make --check output install-method-aware, add auto_update field
liangshuo-1 14defa9
fix: split install method and auto-update capability, fix changelogUR…
liangshuo-1 d31e911
feat: auto-update skills after CLI update via npx skills add
liangshuo-1 87a6b19
fix: include skills error detail in output, add skills update tests
liangshuo-1 5dfc547
fix: use only stderr for skills_detail to avoid stdout noise
liangshuo-1 58efec6
fix: Windows binary lock detection and ASCII symbol fallback for lega…
liangshuo-1 e7ab0d6
test: add Windows binary lock, symbol fallback, and permission hint t…
liangshuo-1 df625e3
fix: Windows --check guidance, PowerShell 5 compat (use ; not &&), ad…
liangshuo-1 e746f13
refactor: extract osWindows const, use numbered steps for Windows hin…
liangshuo-1 bba9cc4
feat: add selfupdate package with binary replacement and rollback
liangshuo-1 bb693a1
feat: integrate selfupdate — backup before upgrade, --rollback flag, …
liangshuo-1 cc71c5f
refactor: remove binary backup/rollback, implement Windows pre-rename…
liangshuo-1 b551c88
fix: robust Windows restore — remove partial files before restoring, …
liangshuo-1 e53c581
refactor: simplify update.go — fix manualReason bug, merge JSON/Human…
liangshuo-1 40c8560
test: add regression assertion for npm-missing manual reason message
liangshuo-1 75819d0
fix: suppress _notice at update command entry, remove unused npmPath …
liangshuo-1 8b67b7a
refactor: split selfupdate into build-tagged platform files (replace_…
liangshuo-1 167d8b6
refactor: use vfs.Remove/Rename/Stat instead of os.* in selfupdate (p…
liangshuo-1 1c2bd6b
refactor: extend vfs with EvalSymlinks/Executable, use Updater struct…
liangshuo-1 6fd467d
refactor: move detection/npm/skills logic into selfupdate.Updater, sl…
liangshuo-1 4201aa8
refactor: rename replace*.go to updater*.go to match Updater struct
liangshuo-1 f237695
fix: add .old recovery to run.js for Windows crash resilience, fix st…
liangshuo-1 9393366
feat: add post-update binary verification, semver prerelease support,…
liangshuo-1 874b4ca
fix: use rune-aware truncation to avoid splitting multi-byte characters
liangshuo-1 5b1ba30
style: fix gofmt alignment in update_test.go
liangshuo-1 5ddf2e3
fix: address review feedback — track backup state, fix npx -y positio…
liangshuo-1 30619ce
fix: use LookPath instead of os.Executable in VerifyBinary
liangshuo-1 f1c6d22
fix: add resolveExe fallback in VerifyBinary for absolute-path invoca…
liangshuo-1 e3105a3
fix: keep backupCreated on Updater struct, reference from both platforms
liangshuo-1 984f73f
fix: guard Windows restore against double-recovery, sync RunSkillsUpd…
liangshuo-1 7eea192
fix: verify current executable first, surface restore failures, fix n…
liangshuo-1 68e08e4
fix: pass -y to both npx and skills to prevent interactive prompts
liangshuo-1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
|
|
||
| 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)) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.