feat: 跨平台命令助手 kopsc 初始实现

- 意图命令按 OS/架构、Linux 发行版与容器运行时解析并执行对应底层命令
- 内置 docker.clearsafe、net.ports、pkg.refreshindex
- --dry-run 预览、list/explain/platform/version 辅助命令
- 纯 Go 跨平台构建脚本 scripts/build
This commit is contained in:
2026-10-31 13:01:39 +08:00
commit 7d9852958f
18 changed files with 2650 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
// Package resolve picks the most specific registry variant for a detected
// platform, and explains why each candidate did or did not match.
package resolve
import (
"errors"
"fmt"
"strings"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
)
// Scoring weights. A variant must match on every declared constraint; the
// score decides among the variants that do match.
const (
scoreDistroExact = 3
scoreDistroLike = 2
scoreRuntime = 2
)
// ErrNoMatch is returned when no variant fits the environment.
var ErrNoMatch = errors.New("no matching variant")
// ErrAmbiguous is returned when two or more variants tie for best match.
var ErrAmbiguous = errors.New("ambiguous match")
// Candidate is one variant together with the evaluation outcome.
type Candidate struct {
Index int
Variant registry.Variant
Score int
// Matched reports whether every declared constraint was satisfied.
Matched bool
// Reason explains the decision in human-readable form.
Reason string
}
// Result is a successful resolution.
type Result struct {
Command *registry.Command
Info platform.Info
Variant registry.Variant
VariantIdx int
Score int
Candidates []Candidate
}
// NoMatchError describes why resolution failed.
type NoMatchError struct {
CommandID string
Info platform.Info
Candidates []Candidate
}
func (e *NoMatchError) Error() string {
return fmt.Sprintf("命令 %q 不支持当前平台 %s", e.CommandID, DescribeInfo(e.Info))
}
// Unwrap lets callers test with errors.Is(err, ErrNoMatch).
func (e *NoMatchError) Unwrap() error { return ErrNoMatch }
// AmbiguousError reports tied candidates.
type AmbiguousError struct {
CommandID string
Info platform.Info
Candidates []Candidate
}
func (e *AmbiguousError) Error() string {
return fmt.Sprintf("命令 %q 在当前平台 %s 上匹配到多个同样具体的变体", e.CommandID, DescribeInfo(e.Info))
}
// Unwrap lets callers test with errors.Is(err, ErrAmbiguous).
func (e *AmbiguousError) Unwrap() error { return ErrAmbiguous }
// Resolve evaluates every variant of cmd against info and returns the single
// most specific match. The returned Result always carries all candidates so
// callers can render an explanation.
func Resolve(cmd *registry.Command, info platform.Info) (Result, error) {
result := Result{Command: cmd, Info: info, VariantIdx: -1}
var best []Candidate
for i, variant := range cmd.Variants {
score, matched, reason := Evaluate(variant.Match, info)
candidate := Candidate{
Index: i,
Variant: variant,
Score: score,
Matched: matched,
Reason: reason,
}
result.Candidates = append(result.Candidates, candidate)
if !matched {
continue
}
switch {
case len(best) == 0:
best = []Candidate{candidate}
case betterThan(candidate, best[0]):
best = []Candidate{candidate}
case ties(candidate, best[0]):
best = append(best, candidate)
}
}
switch {
case len(best) == 0:
return result, &NoMatchError{CommandID: cmd.ID, Info: info, Candidates: result.Candidates}
case len(best) > 1:
return result, &AmbiguousError{CommandID: cmd.ID, Info: info, Candidates: result.Candidates}
}
result.Variant = best[0].Variant
result.VariantIdx = best[0].Index
result.Score = best[0].Score
return result, nil
}
// ResolveID looks a command up by ID or alias and resolves it.
func ResolveID(reg *registry.Registry, id string, info platform.Info) (Result, error) {
cmd, ok := reg.Lookup(id)
if !ok {
return Result{}, fmt.Errorf("未知命令 %q", id)
}
return Resolve(cmd, info)
}
// Evaluate checks a single variant against the environment. It returns the
// specificity score, whether all constraints matched, and the reasoning.
func Evaluate(m registry.Match, info platform.Info) (score int, matched bool, reason string) {
var notes []string
rejected := ""
reject := func(note string) {
if rejected == "" {
rejected = note
}
}
add := func(note string) { notes = append(notes, note) }
if m.OS != "" {
if m.OS != info.OS {
reject(fmt.Sprintf("os=%s 不匹配(当前 %s)", m.OS, info.OS))
} else {
add("os=" + m.OS)
}
}
if m.Arch != "" {
if m.Arch != info.Arch {
reject(fmt.Sprintf("arch=%s 不匹配(当前 %s)", m.Arch, info.Arch))
} else {
add("arch=" + m.Arch)
}
}
if m.Distro != "" {
if m.Distro != info.Distro {
reject(fmt.Sprintf("distro=%s 不匹配(当前 %s)", m.Distro, orUnknown(info.Distro)))
} else {
score += scoreDistroExact
add(fmt.Sprintf("distro=%s (+%d)", m.Distro, scoreDistroExact))
}
}
if len(m.DistroLike) > 0 {
if !distroLikeMatches(m.DistroLike, info) {
reject(fmt.Sprintf("ID_LIKE 要求 %s(当前 %s)", strings.Join(m.DistroLike, "|"), describeDistro(info)))
} else {
score += scoreDistroLike
add(fmt.Sprintf("ID_LIKE∈{%s} (+%d)", strings.Join(m.DistroLike, ","), scoreDistroLike))
}
}
if m.Runtime != "" {
if !contains(info.Runtimes, m.Runtime) {
reject(fmt.Sprintf("需要运行时 %s(未在 PATH 找到)", m.Runtime))
} else {
score += scoreRuntime
add(fmt.Sprintf("runtime=%s (+%d)", m.Runtime, scoreRuntime))
}
}
if rejected != "" {
return 0, false, rejected
}
if len(notes) == 0 {
return 0, true, "通用变体(无平台约束, 0 分)"
}
return score, true, "匹配 " + strings.Join(notes, ", ")
}
// runtimeRank orders runtimes by preference when two variants are otherwise
// equally specific: podman beats docker because it is the rootless default on
// CentOS-family hosts, and a variant with no runtime constraint ranks last.
func runtimeRank(runtime string) int {
switch strings.ToLower(runtime) {
case "podman":
return 0
case "docker":
return 1
case "":
return 3
default:
return 2
}
}
// betterThan reports whether a is a strictly better match than b: higher
// score, then the runtime preference, then the more constrained variant.
func betterThan(a, b Candidate) bool {
if a.Score != b.Score {
return a.Score > b.Score
}
if ra, rb := runtimeRank(a.Variant.Match.Runtime), runtimeRank(b.Variant.Match.Runtime); ra != rb {
return ra < rb
}
return specificity(a.Variant.Match) > specificity(b.Variant.Match)
}
// ties reports whether two candidates are equally good, in which case the
// resolution is ambiguous rather than silently picking one.
func ties(a, b Candidate) bool {
return a.Score == b.Score &&
runtimeRank(a.Variant.Match.Runtime) == runtimeRank(b.Variant.Match.Runtime) &&
specificity(a.Variant.Match) == specificity(b.Variant.Match)
}
// specificity counts the constraints a variant declares, so an OS-only
// variant outranks a fully generic one at equal score.
func specificity(m registry.Match) int {
n := 0
if m.OS != "" {
n++
}
if m.Arch != "" {
n++
}
if m.Distro != "" {
n++
}
if len(m.DistroLike) > 0 {
n++
}
if m.Runtime != "" {
n++
}
return n
}
func distroLikeMatches(want []string, info platform.Info) bool {
current := make([]string, 0, 1+len(info.DistroLike))
if info.Distro != "" {
current = append(current, info.Distro)
}
current = append(current, info.DistroLike...)
for _, c := range current {
for _, w := range want {
if strings.EqualFold(c, w) {
return true
}
}
}
return false
}
func contains(values []string, want string) bool {
for _, v := range values {
if strings.EqualFold(v, want) {
return true
}
}
return false
}
func orUnknown(s string) string {
if s == "" {
return "未知"
}
return s
}
func describeDistro(info platform.Info) string {
if info.Distro == "" && len(info.DistroLike) == 0 {
return "未知"
}
if len(info.DistroLike) == 0 {
return info.Distro
}
return info.Distro + " (" + strings.Join(info.DistroLike, " ") + ")"
}
// DescribeInfo renders the environment compactly for error messages.
func DescribeInfo(info platform.Info) string {
s := info.OS + "/" + info.Arch
if info.Distro != "" {
s += ", distro=" + info.Distro
}
if len(info.Runtimes) > 0 {
s += ", runtimes=" + strings.Join(info.Runtimes, ",")
}
return s
}