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
}
+294
View File
@@ -0,0 +1,294 @@
package resolve
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
)
func env(osName, arch, distro string, like []string, runtimes ...string) platform.Info {
return platform.Info{
OS: osName,
Arch: arch,
Distro: distro,
DistroLike: like,
Runtimes: runtimes,
}
}
func builtin(t *testing.T, id string) *registry.Command {
t.Helper()
cmd, ok := registry.Builtin().Lookup(id)
if !ok {
t.Fatalf("builtin command %q missing", id)
}
return cmd
}
func TestResolveBuiltin(t *testing.T) {
tests := []struct {
name string
id string
info platform.Info
wantArgv []string
wantScore int
wantIdx int
}{
{
name: "centos with podman and docker prefers the distro-specific podman variant",
id: "docker.clearsafe",
info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker", "podman"),
wantArgv: []string{"podman", "system", "prune", "-f"},
wantScore: 5,
wantIdx: 0,
},
{
name: "centos with docker only",
id: "docker.clearsafe",
info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker"),
wantArgv: []string{"docker", "system", "prune", "-f"},
wantScore: 5,
wantIdx: 1,
},
{
name: "generic linux with podman and docker prefers podman",
id: "docker.clearsafe",
info: env("linux", "amd64", "ubuntu", []string{"debian"}, "docker", "podman"),
wantArgv: []string{"podman", "system", "prune", "-f"},
wantScore: 2,
wantIdx: 2,
},
{
name: "rocky inherits the generic linux docker variant",
id: "docker.clearsafe",
info: env("linux", "amd64", "rocky", []string{"rhel", "centos"}, "docker"),
wantArgv: []string{"docker", "system", "prune", "-f"},
wantScore: 2,
wantIdx: 3,
},
{
name: "darwin docker",
id: "docker.clearsafe",
info: env("darwin", "arm64", "", nil, "docker"),
wantArgv: []string{"docker", "system", "prune", "-f"},
wantScore: 2,
wantIdx: 4,
},
{
name: "windows docker",
id: "docker.clearsafe",
info: env("windows", "amd64", "", nil, "docker"),
wantArgv: []string{"docker", "system", "prune", "-f"},
wantScore: 2,
wantIdx: 5,
},
{
name: "centos refreshes with dnf exactly",
id: "pkg.refreshindex",
info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}),
wantArgv: []string{"dnf", "makecache"},
wantScore: 3,
wantIdx: 0,
},
{
name: "rocky inherits rhel-like dnf",
id: "pkg.refreshindex",
info: env("linux", "amd64", "rocky", []string{"rhel"}),
wantArgv: []string{"dnf", "makecache"},
wantScore: 2,
wantIdx: 1,
},
{
name: "ubuntu resolves through ID_LIKE debian",
id: "pkg.refreshindex",
info: env("linux", "amd64", "ubuntu", []string{"debian"}),
wantArgv: []string{"apt-get", "update"},
wantScore: 2,
wantIdx: 2,
},
{
name: "alpine has its own distro variant",
id: "pkg.refreshindex",
info: env("linux", "amd64", "alpine", nil),
wantArgv: []string{"apk", "update"},
wantScore: 3,
wantIdx: 3,
},
{
name: "darwin homebrew",
id: "pkg.refreshindex",
info: env("darwin", "arm64", "", nil),
wantArgv: []string{"brew", "update"},
wantIdx: 4,
},
{
name: "windows winget",
id: "pkg.refreshindex",
info: env("windows", "amd64", "", nil),
wantArgv: []string{"winget", "source", "update"},
wantIdx: 5,
},
{
name: "linux listens with ss",
id: "net.ports",
info: env("linux", "amd64", "centos", []string{"rhel"}),
wantArgv: []string{"ss", "-tulnp"},
wantIdx: 0,
},
{
name: "darwin listens with lsof",
id: "net.ports",
info: env("darwin", "amd64", "", nil),
wantArgv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"},
wantIdx: 1,
},
{
name: "windows listens with netstat",
id: "net.ports",
info: env("windows", "amd64", "", nil),
wantArgv: []string{"netstat", "-ano"},
wantIdx: 2,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := Resolve(builtin(t, tc.id), tc.info)
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if !reflect.DeepEqual(got.Variant.Argv, tc.wantArgv) {
t.Errorf("argv = %v, want %v", got.Variant.Argv, tc.wantArgv)
}
if got.VariantIdx != tc.wantIdx {
t.Errorf("variant index = %d, want %d", got.VariantIdx, tc.wantIdx)
}
if got.Score != tc.wantScore {
t.Errorf("score = %d, want %d", got.Score, tc.wantScore)
}
if len(got.Candidates) != len(got.Command.Variants) {
t.Errorf("candidates = %d, want %d", len(got.Candidates), len(got.Command.Variants))
}
})
}
}
func TestResolveNoMatch(t *testing.T) {
info := env("linux", "amd64", "centos", []string{"rhel"}) // no container runtime on PATH
cmd := builtin(t, "docker.clearsafe")
_, err := Resolve(cmd, info)
if err == nil {
t.Fatal("expected an error without a container runtime")
}
if !errors.Is(err, ErrNoMatch) {
t.Fatalf("error = %v, want ErrNoMatch", err)
}
var noMatch *NoMatchError
if !errors.As(err, &noMatch) {
t.Fatalf("error type = %T, want *NoMatchError", err)
}
if len(noMatch.Candidates) != len(cmd.Variants) {
t.Errorf("candidates = %d, want %d", len(noMatch.Candidates), len(cmd.Variants))
}
for _, c := range noMatch.Candidates {
if c.Matched {
t.Errorf("candidate %v should not match", c.Variant.Argv)
}
if c.Reason == "" {
t.Errorf("candidate %v has no reason", c.Variant.Argv)
}
}
if !strings.Contains(err.Error(), "runtime") && !strings.Contains(err.Error(), "不支持") {
t.Errorf("error message lacks detail: %v", err)
}
}
func TestResolveAmbiguous(t *testing.T) {
cmd := &registry.Command{
ID: "demo.ambiguous",
Summary: "two equally generic variants",
Variants: []registry.Variant{
{Argv: []string{"one"}},
{Argv: []string{"two"}},
},
}
_, err := Resolve(cmd, env("linux", "amd64", "", nil))
if err == nil {
t.Fatal("expected an ambiguity error")
}
if !errors.Is(err, ErrAmbiguous) {
t.Fatalf("error = %v, want ErrAmbiguous", err)
}
var ambiguous *AmbiguousError
if !errors.As(err, &ambiguous) {
t.Fatalf("error type = %T, want *AmbiguousError", err)
}
}
func TestResolveArchMismatch(t *testing.T) {
cmd := &registry.Command{
ID: "demo.arch",
Summary: "arm64 only",
Variants: []registry.Variant{
{Match: registry.Match{OS: "linux", Arch: "arm64"}, Argv: []string{"arm"}},
},
}
_, err := Resolve(cmd, env("linux", "amd64", "", nil))
if !errors.Is(err, ErrNoMatch) {
t.Fatalf("error = %v, want ErrNoMatch", err)
}
}
func TestResolveSpecificBeatsGeneric(t *testing.T) {
cmd := &registry.Command{
ID: "demo.specific",
Summary: "specific and generic",
Variants: []registry.Variant{
{Argv: []string{"generic"}},
{Match: registry.Match{OS: "linux"}, Argv: []string{"linux"}},
},
}
got, err := Resolve(cmd, env("linux", "amd64", "centos", []string{"rhel"}))
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
// Both score 0 here, so the runtime tier and then declaration order decide;
// the important property is that a single variant wins.
if len(got.Variant.Argv) == 0 {
t.Fatal("no variant chosen")
}
}
func TestEvaluateRejects(t *testing.T) {
info := env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker")
cases := []struct {
name string
match registry.Match
want string
}{
{"os", registry.Match{OS: "darwin"}, "os=darwin"},
{"arch", registry.Match{Arch: "arm64"}, "arch=arm64"},
{"distro", registry.Match{Distro: "debian"}, "distro=debian"},
{"distro like", registry.Match{DistroLike: []string{"debian"}}, "ID_LIKE"},
{"runtime", registry.Match{Runtime: "podman"}, "podman"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, matched, reason := Evaluate(tc.match, info)
if matched {
t.Fatalf("match should fail: %+v", tc.match)
}
if !strings.Contains(reason, tc.want) {
t.Fatalf("reason %q does not contain %q", reason, tc.want)
}
})
}
}