mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
266 lines
6.5 KiB
Go
266 lines
6.5 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package skillscheck
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/selfupdate"
|
|
)
|
|
|
|
var skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
|
|
|
|
type SyncInput struct {
|
|
Version string
|
|
OfficialSkills []string
|
|
LocalSkills []string
|
|
PreviousState *SkillsState
|
|
StateReadable bool
|
|
Force bool
|
|
}
|
|
|
|
type SyncPlan struct {
|
|
Version string
|
|
OfficialSkills []string
|
|
ToUpdate []string
|
|
Added []string
|
|
SkippedDeleted []string
|
|
}
|
|
|
|
func ParseSkillsList(text string) []string {
|
|
seen := map[string]bool{}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
token := strings.TrimSpace(line)
|
|
token = strings.TrimPrefix(token, "-")
|
|
token = strings.TrimSpace(token)
|
|
if token == "" || strings.Contains(token, " ") || strings.HasSuffix(token, ":") {
|
|
continue
|
|
}
|
|
if !skillNamePattern.MatchString(token) {
|
|
continue
|
|
}
|
|
if at := strings.Index(token, "@"); at > 0 {
|
|
token = token[:at]
|
|
}
|
|
seen[token] = true
|
|
}
|
|
return sortedKeys(seen)
|
|
}
|
|
|
|
func PlanSync(input SyncInput) SyncPlan {
|
|
official := uniqueSorted(input.OfficialSkills)
|
|
if input.Force {
|
|
return SyncPlan{
|
|
Version: input.Version,
|
|
OfficialSkills: official,
|
|
ToUpdate: official,
|
|
Added: []string{},
|
|
SkippedDeleted: []string{},
|
|
}
|
|
}
|
|
|
|
officialSet := toSet(official)
|
|
localOfficial := intersection(input.LocalSkills, officialSet)
|
|
|
|
previousOfficial := []string{}
|
|
if input.StateReadable && input.PreviousState != nil {
|
|
previousOfficial = input.PreviousState.OfficialSkills
|
|
}
|
|
previousSet := toSet(previousOfficial)
|
|
|
|
newOfficial := []string{}
|
|
for _, skill := range official {
|
|
if !previousSet[skill] {
|
|
newOfficial = append(newOfficial, skill)
|
|
}
|
|
}
|
|
|
|
updateSet := toSet(localOfficial)
|
|
for _, skill := range newOfficial {
|
|
updateSet[skill] = true
|
|
}
|
|
toUpdate := sortedKeys(updateSet)
|
|
updateSet = toSet(toUpdate)
|
|
|
|
skipped := []string{}
|
|
for _, skill := range official {
|
|
if !updateSet[skill] {
|
|
skipped = append(skipped, skill)
|
|
}
|
|
}
|
|
|
|
return SyncPlan{
|
|
Version: input.Version,
|
|
OfficialSkills: official,
|
|
ToUpdate: toUpdate,
|
|
Added: uniqueSorted(newOfficial),
|
|
SkippedDeleted: skipped,
|
|
}
|
|
}
|
|
|
|
type SkillsRunner interface {
|
|
ListOfficialSkills() *selfupdate.NpmResult
|
|
ListGlobalSkills() *selfupdate.NpmResult
|
|
InstallSkill(name string) *selfupdate.NpmResult
|
|
}
|
|
|
|
type SyncOptions struct {
|
|
Version string
|
|
Force bool
|
|
Runner SkillsRunner
|
|
Now func() time.Time
|
|
}
|
|
|
|
type SyncResult struct {
|
|
Action string
|
|
Official []string
|
|
Updated []string
|
|
Added []string
|
|
SkippedDeleted []string
|
|
Failed []string
|
|
Err error
|
|
Detail string
|
|
Force bool
|
|
}
|
|
|
|
func SyncSkills(opts SyncOptions) *SyncResult {
|
|
if opts.Now == nil {
|
|
opts.Now = time.Now
|
|
}
|
|
if opts.Runner == nil {
|
|
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
|
|
}
|
|
|
|
officialResult := opts.Runner.ListOfficialSkills()
|
|
if officialResult == nil {
|
|
return &SyncResult{Action: "failed", Err: fmt.Errorf("failed to list official skills: empty result")}
|
|
}
|
|
if officialResult.Err != nil {
|
|
return &SyncResult{Action: "failed", Err: fmt.Errorf("failed to list official skills: %w", officialResult.Err), Detail: resultDetail(officialResult)}
|
|
}
|
|
official := ParseSkillsList(officialResult.Stdout.String())
|
|
|
|
localResult := opts.Runner.ListGlobalSkills()
|
|
if localResult == nil {
|
|
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to list installed skills: empty result")}
|
|
}
|
|
if localResult.Err != nil {
|
|
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to list installed skills: %w", localResult.Err), Detail: resultDetail(localResult)}
|
|
}
|
|
local := ParseSkillsList(localResult.Stdout.String())
|
|
|
|
previous, readable, err := ReadState()
|
|
if err != nil {
|
|
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to read skills state: %w", err)}
|
|
}
|
|
|
|
plan := PlanSync(SyncInput{
|
|
Version: opts.Version,
|
|
OfficialSkills: official,
|
|
LocalSkills: local,
|
|
PreviousState: previous,
|
|
StateReadable: readable,
|
|
Force: opts.Force,
|
|
})
|
|
|
|
result := &SyncResult{
|
|
Action: "synced",
|
|
Official: plan.OfficialSkills,
|
|
Updated: plan.ToUpdate,
|
|
Added: plan.Added,
|
|
SkippedDeleted: plan.SkippedDeleted,
|
|
Force: opts.Force,
|
|
}
|
|
|
|
failed := []string{}
|
|
var details []string
|
|
for _, skill := range plan.ToUpdate {
|
|
installResult := opts.Runner.InstallSkill(skill)
|
|
if installResult == nil {
|
|
failed = append(failed, skill)
|
|
details = append(details, skill+": empty result")
|
|
continue
|
|
}
|
|
if installResult.Err != nil {
|
|
failed = append(failed, skill)
|
|
details = append(details, skill+": "+resultDetail(installResult))
|
|
}
|
|
}
|
|
if len(failed) > 0 {
|
|
result.Action = "failed"
|
|
result.Failed = failed
|
|
result.Err = fmt.Errorf("%d skill(s) failed", len(failed))
|
|
result.Detail = strings.Join(details, "\n")
|
|
return result
|
|
}
|
|
|
|
state := SkillsState{
|
|
Version: opts.Version,
|
|
OfficialSkills: plan.OfficialSkills,
|
|
UpdatedSkills: plan.ToUpdate,
|
|
AddedSkills: plan.Added,
|
|
SkippedDeletedSkills: plan.SkippedDeleted,
|
|
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if err := WriteState(state); err != nil {
|
|
result.Action = "failed"
|
|
result.Err = fmt.Errorf("skills synced but state not written: %w", err)
|
|
return result
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func resultDetail(result *selfupdate.NpmResult) string {
|
|
if result == nil {
|
|
return ""
|
|
}
|
|
parts := []string{}
|
|
if output := strings.TrimSpace(result.CombinedOutput()); output != "" {
|
|
parts = append(parts, output)
|
|
}
|
|
if result.Err != nil {
|
|
parts = append(parts, result.Err.Error())
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func uniqueSorted(values []string) []string {
|
|
return sortedKeys(toSet(values))
|
|
}
|
|
|
|
func toSet(values []string) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
out[value] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func intersection(values []string, allowed map[string]bool) []string {
|
|
out := map[string]bool{}
|
|
for _, value := range values {
|
|
if allowed[value] {
|
|
out[value] = true
|
|
}
|
|
}
|
|
return sortedKeys(out)
|
|
}
|
|
|
|
func sortedKeys(values map[string]bool) []string {
|
|
out := make([]string, 0, len(values))
|
|
for value := range values {
|
|
out = append(out, value)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|