mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
feat: add --skills flag to update for suite mode
This commit is contained in:
@@ -86,10 +86,12 @@ func symArrow() string {
|
||||
|
||||
// UpdateOptions holds inputs for the update command.
|
||||
type UpdateOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
Skills []string
|
||||
SuiteProvided bool
|
||||
}
|
||||
|
||||
// NewCmdUpdate creates the update command.
|
||||
@@ -108,6 +110,7 @@ Detects the installation method automatically:
|
||||
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 {
|
||||
opts.SuiteProvided = cmd.Flags().Changed("skills")
|
||||
return updateRun(opts)
|
||||
},
|
||||
}
|
||||
@@ -115,6 +118,8 @@ Use --check to only check for updates without installing.`,
|
||||
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")
|
||||
cmd.Flags().StringSliceVar(&opts.Skills, "skills", nil,
|
||||
"comma-separated lark skill names to install and remember (the suite); use --skills all to reset to all official skills")
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
|
||||
return cmd
|
||||
@@ -125,6 +130,18 @@ func updateRun(opts *UpdateOptions) error {
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
// 早期格式校验:在任何网络/安装动作之前 fail-fast。
|
||||
var suite *skillscheck.SuiteSelection
|
||||
if opts.SuiteProvided {
|
||||
parsed, err := skillscheck.ParseSuiteSelection(opts.Skills)
|
||||
if err != nil {
|
||||
return reportError(opts, io, "validation",
|
||||
errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithHint("e.g. --skills lark-calendar,lark-im (or --skills all to reset)"))
|
||||
}
|
||||
suite = parsed
|
||||
}
|
||||
|
||||
if !opts.Check {
|
||||
updater.CleanupStaleFiles()
|
||||
}
|
||||
@@ -147,7 +164,10 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force, suite)
|
||||
if err := suiteInputError(opts, io, skillsResult); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -162,9 +182,9 @@ func updateRun(opts *UpdateOptions) error {
|
||||
|
||||
// 6. Execute update
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||
return doManualUpdate(opts, io, cur, latest, detect, updater, suite)
|
||||
}
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
return doNpmUpdate(opts, io, cur, latest, updater, suite)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
@@ -207,8 +227,11 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
return nil
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater, suite *skillscheck.SuiteSelection) error {
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, suite)
|
||||
if err := suiteInputError(opts, io, skillsResult); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -231,7 +254,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
return nil
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater, suite *skillscheck.SuiteSelection) error {
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, "update_error",
|
||||
@@ -287,7 +310,10 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force, suite)
|
||||
if err := suiteInputError(opts, io, skillsResult); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -324,8 +350,8 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
if !force {
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, suite *skillscheck.SuiteSelection) *skillscheck.SyncResult {
|
||||
if !force && suite == nil {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
return nil
|
||||
}
|
||||
@@ -334,6 +360,7 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
Suite: suite,
|
||||
})
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
@@ -341,6 +368,17 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
|
||||
return result
|
||||
}
|
||||
|
||||
// suiteInputError 把 suite 名字非法(InvalidInput)的 sync 结果映射为退出码 2 的 validation 错误。
|
||||
// 返回 nil 表示不是输入错误,调用方继续正常输出流程。
|
||||
func suiteInputError(opts *UpdateOptions, io *cmdutil.IOStreams, r *skillscheck.SyncResult) error {
|
||||
if r == nil || !r.InvalidInput {
|
||||
return nil
|
||||
}
|
||||
return reportError(opts, io, "validation",
|
||||
errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", r.Err).
|
||||
WithHint("use a valid official skill name; nothing was installed"))
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
// already-up-to-date branch, including any skills_action / skills_warning
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
@@ -402,6 +440,9 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
||||
env["skills_action"] = "synced"
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
}
|
||||
if r != nil && len(r.Suite) > 0 {
|
||||
env["skills_suite"] = r.Suite
|
||||
}
|
||||
}
|
||||
|
||||
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
@@ -434,4 +475,7 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
}
|
||||
if r != nil && len(r.Suite) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " Suite: %s (run `lark-cli update --skills all` to restore all)\n", strings.Join(r.Suite, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1006,7 +1007,7 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, nil)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
@@ -1027,7 +1028,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true, nil)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
}
|
||||
@@ -1039,7 +1040,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, nil)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
@@ -1064,7 +1065,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, nil)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
@@ -1357,7 +1358,7 @@ func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false, nil)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
}
|
||||
@@ -1594,3 +1595,175 @@ func containsString(values []string, target string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// captureSyncSkills 替换 syncSkills,记录传入的 SyncOptions 并返回固定结果。
|
||||
func captureSyncSkills(t *testing.T, result *skillscheck.SyncResult) *skillscheck.SyncOptions {
|
||||
t.Helper()
|
||||
var captured skillscheck.SyncOptions
|
||||
orig := syncSkills
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
||||
captured = opts
|
||||
return result
|
||||
}
|
||||
t.Cleanup(func() { syncSkills = orig })
|
||||
return &captured
|
||||
}
|
||||
|
||||
func TestUpdate_SkillsFlagParsedIntoSuite(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--skills", "lark-calendar,lark-im"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" } // already up to date path
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
captured := captureSyncSkills(t, &skillscheck.SyncResult{
|
||||
Action: "synced", Official: []string{"lark-calendar", "lark-im"},
|
||||
Updated: []string{"lark-calendar", "lark-im"}, Suite: []string{"lark-calendar", "lark-im"},
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() err = %v", err)
|
||||
}
|
||||
if captured.Suite == nil || captured.Suite.All {
|
||||
t.Fatalf("captured.Suite = %#v, want explicit list", captured.Suite)
|
||||
}
|
||||
if !reflect.DeepEqual(captured.Suite.Skills, []string{"lark-calendar", "lark-im"}) {
|
||||
t.Fatalf("captured.Suite.Skills = %#v, want [lark-calendar lark-im]", captured.Suite.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_SkillsAllParsedAsReset(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--skills", "all"})
|
||||
|
||||
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 }()
|
||||
|
||||
captured := captureSyncSkills(t, &skillscheck.SyncResult{Action: "synced"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() err = %v", err)
|
||||
}
|
||||
if captured.Suite == nil || !captured.Suite.All {
|
||||
t.Fatalf("captured.Suite = %#v, want All=true", captured.Suite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_InvalidSkillsFlag_JSONExit2(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--skills", "all,lark-im"})
|
||||
|
||||
// fetchLatest must NOT be called — validation happens first.
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) {
|
||||
t.Fatal("fetchLatest called before --skills validation")
|
||||
return "", nil
|
||||
}
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d (ExitValidation)", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"type": "validation"`) {
|
||||
t.Fatalf("JSON output missing validation type: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_UnknownSkillResult_Exit2(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--skills", "lark-bogus"})
|
||||
|
||||
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 }()
|
||||
|
||||
captureSyncSkills(t, &skillscheck.SyncResult{
|
||||
Action: "failed", InvalidInput: true,
|
||||
Err: errors.New("unknown skill(s) not in official list: lark-bogus"),
|
||||
})
|
||||
|
||||
err := cmd.Execute()
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_SkillsSuiteInJSONOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--skills", "lark-im"})
|
||||
|
||||
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 }()
|
||||
|
||||
captureSyncSkills(t, &skillscheck.SyncResult{
|
||||
Action: "synced", Official: []string{"lark-im"}, Updated: []string{"lark-im"},
|
||||
Suite: []string{"lark-im"},
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() err = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"skills_suite"`) {
|
||||
t.Fatalf("JSON output missing skills_suite: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_SkillsFlagBypassesVersionEarlyReturn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// State synced at the same version → without --skills this would skip sync.
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
||||
Version: "1.0.0", UpdatedAt: "2026-06-26T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, _, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--skills", "lark-im"})
|
||||
|
||||
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 }()
|
||||
|
||||
called := false
|
||||
orig := syncSkills
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
||||
called = true
|
||||
return &skillscheck.SyncResult{Action: "synced", Suite: []string{"lark-im"}}
|
||||
}
|
||||
t.Cleanup(func() { syncSkills = orig })
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() err = %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("syncSkills not called — --skills must bypass the same-version early return")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user