feat: 跨平台命令助手 kopsc 初始实现
- 意图命令按 OS/架构、Linux 发行版与容器运行时解析并执行对应底层命令 - 内置 docker.clearsafe、net.ports、pkg.refreshindex - --dry-run 预览、list/explain/platform/version 辅助命令 - 纯 Go 跨平台构建脚本 scripts/build
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package registry
|
||||
|
||||
// Builtin returns the registry compiled into kopsc. Every variant is explicit
|
||||
// about the platform it targets; resolution picks the most specific match.
|
||||
func Builtin() *Registry {
|
||||
return &Registry{Commands: []Command{
|
||||
{
|
||||
ID: "docker.clearsafe",
|
||||
Aliases: []string{"docker.prunesafe"},
|
||||
Group: "docker",
|
||||
Summary: "安全清理未使用的容器、网络与构建缓存",
|
||||
Action: "执行容器运行时的默认清理作用域:不清理全部未使用镜像(-a)," +
|
||||
"不删除卷(--volumes),仅移除停止的容器、未使用的网络和悬空镜像",
|
||||
Variants: []Variant{
|
||||
{
|
||||
Match: Match{OS: "linux", Distro: "centos", Runtime: "podman"},
|
||||
Argv: []string{"podman", "system", "prune", "-f"},
|
||||
Note: "CentOS 上的 Podman 运行时",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "linux", Distro: "centos", Runtime: "docker"},
|
||||
Argv: []string{"docker", "system", "prune", "-f"},
|
||||
Note: "CentOS 上的 Docker 运行时",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "linux", Runtime: "podman"},
|
||||
Argv: []string{"podman", "system", "prune", "-f"},
|
||||
Note: "任意 Linux 发行版上的 Podman 运行时",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "linux", Runtime: "docker"},
|
||||
Argv: []string{"docker", "system", "prune", "-f"},
|
||||
Note: "任意 Linux 发行版上的 Docker 运行时",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "darwin", Runtime: "docker"},
|
||||
Argv: []string{"docker", "system", "prune", "-f"},
|
||||
Note: "macOS 上的 Docker Desktop 运行时",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "windows", Runtime: "docker"},
|
||||
Argv: []string{"docker", "system", "prune", "-f"},
|
||||
Note: "Windows 上的 Docker Desktop 运行时",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "net.ports",
|
||||
Group: "net",
|
||||
Summary: "列出本机正在监听的 TCP/UDP 端口",
|
||||
Action: "以只读方式列出监听中的套接字及持有进程",
|
||||
Variants: []Variant{
|
||||
{
|
||||
Match: Match{OS: "linux"},
|
||||
Argv: []string{"ss", "-tulnp"},
|
||||
Note: "Linux 使用 iproute2 的 ss",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "darwin"},
|
||||
Argv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"},
|
||||
Note: "macOS 使用 lsof",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "windows"},
|
||||
Argv: []string{"netstat", "-ano"},
|
||||
Note: "Windows 使用 netstat",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "pkg.refreshindex",
|
||||
Group: "pkg",
|
||||
Summary: "刷新系统包管理器的软件源索引",
|
||||
Action: "只更新元数据索引,不升级或安装任何软件包",
|
||||
Variants: []Variant{
|
||||
{
|
||||
Match: Match{OS: "linux", Distro: "centos"},
|
||||
Argv: []string{"dnf", "makecache"},
|
||||
Note: "CentOS 使用 dnf",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "linux", DistroLike: []string{"rhel", "fedora"}},
|
||||
Argv: []string{"dnf", "makecache"},
|
||||
Note: "RHEL/Fedora 系发行版使用 dnf",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "linux", DistroLike: []string{"debian", "ubuntu"}},
|
||||
Argv: []string{"apt-get", "update"},
|
||||
Note: "Debian/Ubuntu 系发行版使用 apt-get",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "linux", Distro: "alpine"},
|
||||
Argv: []string{"apk", "update"},
|
||||
Note: "Alpine 使用 apk",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "darwin"},
|
||||
Argv: []string{"brew", "update"},
|
||||
Note: "macOS 使用 Homebrew",
|
||||
},
|
||||
{
|
||||
Match: Match{OS: "windows"},
|
||||
Argv: []string{"winget", "source", "update"},
|
||||
Note: "Windows 使用 winget",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Package registry holds kopsc's built-in command registry: platform-neutral
|
||||
// intents, each with one or more platform-specific variants.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Match constrains a Variant to environments. An empty field means "any".
|
||||
// A Variant with a completely empty Match is a generic fallback.
|
||||
type Match struct {
|
||||
OS string // runtime.GOOS
|
||||
Arch string // runtime.GOARCH
|
||||
Distro string // exact os-release ID, e.g. "centos"
|
||||
DistroLike []string // os-release ID_LIKE ancestry, e.g. "rhel"
|
||||
Runtime string // container runtime required on PATH, e.g. "podman"
|
||||
}
|
||||
|
||||
// Variant is one concrete implementation of a command.
|
||||
type Variant struct {
|
||||
Match Match
|
||||
Argv []string // program followed by arguments
|
||||
Shell bool // run through the platform shell instead of exec'ing Argv[0]
|
||||
Note string // human-readable rationale shown by `kopsc explain`
|
||||
}
|
||||
|
||||
// Command is a platform-neutral intent, e.g. "docker.clearsafe".
|
||||
type Command struct {
|
||||
ID string // hierarchical, dot-separated: "docker.clearsafe"
|
||||
Aliases []string
|
||||
Summary string
|
||||
Group string // CLI grouping, e.g. "docker"
|
||||
Action string // what the command does, including its safety contract
|
||||
Variants []Variant
|
||||
}
|
||||
|
||||
// Registry is an ordered set of commands.
|
||||
type Registry struct {
|
||||
Commands []Command
|
||||
}
|
||||
|
||||
// Validate reports structural problems: empty or duplicate identifiers,
|
||||
// empty aliases, commands without variants and variants without an argv.
|
||||
func (r *Registry) Validate() error {
|
||||
seenIDs := make(map[string]bool)
|
||||
seenAliases := make(map[string]string)
|
||||
|
||||
for i, cmd := range r.Commands {
|
||||
if strings.TrimSpace(cmd.ID) == "" {
|
||||
return fmt.Errorf("command #%d: empty ID", i)
|
||||
}
|
||||
if seenIDs[cmd.ID] {
|
||||
return fmt.Errorf("command #%d: duplicate ID %q", i, cmd.ID)
|
||||
}
|
||||
seenIDs[cmd.ID] = true
|
||||
|
||||
if strings.TrimSpace(cmd.Summary) == "" {
|
||||
return fmt.Errorf("command %q: empty summary", cmd.ID)
|
||||
}
|
||||
if len(cmd.Variants) == 0 {
|
||||
return fmt.Errorf("command %q: no variants", cmd.ID)
|
||||
}
|
||||
|
||||
for _, alias := range cmd.Aliases {
|
||||
if strings.TrimSpace(alias) == "" {
|
||||
return fmt.Errorf("command %q: empty alias", cmd.ID)
|
||||
}
|
||||
if owner, ok := seenAliases[alias]; ok {
|
||||
return fmt.Errorf("command %q: alias %q already used by %q", cmd.ID, alias, owner)
|
||||
}
|
||||
seenAliases[alias] = cmd.ID
|
||||
}
|
||||
|
||||
for j, variant := range cmd.Variants {
|
||||
if len(variant.Argv) == 0 || strings.TrimSpace(variant.Argv[0]) == "" {
|
||||
return fmt.Errorf("command %q variant #%d: empty argv", cmd.ID, j)
|
||||
}
|
||||
if err := variant.Match.validate(); err != nil {
|
||||
return fmt.Errorf("command %q variant #%d: %w", cmd.ID, j, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Match) validate() error {
|
||||
for _, field := range m.DistroLike {
|
||||
if strings.TrimSpace(field) == "" {
|
||||
return fmt.Errorf("empty DistroLike entry")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lookup finds a command by ID or alias.
|
||||
func (r *Registry) Lookup(idOrAlias string) (*Command, bool) {
|
||||
for i := range r.Commands {
|
||||
cmd := &r.Commands[i]
|
||||
if cmd.ID == idOrAlias {
|
||||
return cmd, true
|
||||
}
|
||||
for _, alias := range cmd.Aliases {
|
||||
if alias == idOrAlias {
|
||||
return cmd, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// IDs returns every command ID in registry order.
|
||||
func (r *Registry) IDs() []string {
|
||||
ids := make([]string, 0, len(r.Commands))
|
||||
for _, cmd := range r.Commands {
|
||||
ids = append(ids, cmd.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuiltinIsValid(t *testing.T) {
|
||||
reg := Builtin()
|
||||
if err := reg.Validate(); err != nil {
|
||||
t.Fatalf("Builtin() is invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinIDsStable(t *testing.T) {
|
||||
reg := Builtin()
|
||||
want := []string{"docker.clearsafe", "net.ports", "pkg.refreshindex"}
|
||||
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("IDs() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinClearsafeIsSafe(t *testing.T) {
|
||||
reg := Builtin()
|
||||
cmd, ok := reg.Lookup("docker.clearsafe")
|
||||
if !ok {
|
||||
t.Fatal("docker.clearsafe not found")
|
||||
}
|
||||
if len(cmd.Variants) < 4 {
|
||||
t.Fatalf("expected a variant per platform, got %d", len(cmd.Variants))
|
||||
}
|
||||
|
||||
for _, variant := range cmd.Variants {
|
||||
if len(variant.Argv) == 0 || (variant.Argv[0] != "docker" && variant.Argv[0] != "podman") {
|
||||
t.Errorf("unexpected program %v", variant.Argv)
|
||||
}
|
||||
if strings.Join(variant.Argv, " ") != variant.Argv[0]+" system prune -f" {
|
||||
t.Errorf("clearsafe must stay scoped to `system prune -f`, got %v", variant.Argv)
|
||||
}
|
||||
for _, forbidden := range []string{"-a", "--all", "--volumes", "-v", "--force=false"} {
|
||||
for _, arg := range variant.Argv {
|
||||
if arg == forbidden {
|
||||
t.Errorf("variant %v must not pass %q", variant.Argv, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupAlias(t *testing.T) {
|
||||
reg := Builtin()
|
||||
cmd, ok := reg.Lookup("docker.prunesafe")
|
||||
if !ok {
|
||||
t.Fatal("alias lookup failed")
|
||||
}
|
||||
if cmd.ID != "docker.clearsafe" {
|
||||
t.Fatalf("alias resolved to %q", cmd.ID)
|
||||
}
|
||||
if _, ok := reg.Lookup("does.not.exist"); ok {
|
||||
t.Fatal("unexpected lookup hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantPlatformsExplicit(t *testing.T) {
|
||||
for _, cmd := range Builtin().Commands {
|
||||
for _, variant := range cmd.Variants {
|
||||
if variant.Match.OS == "" {
|
||||
t.Errorf("command %q has a variant without an OS constraint: %v", cmd.ID, variant.Argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func plainCommand(id string) Command {
|
||||
return Command{
|
||||
ID: id,
|
||||
Summary: "summary",
|
||||
Variants: []Variant{{Argv: []string{"x"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateErrors(t *testing.T) {
|
||||
withBlankDistroLike := plainCommand("a")
|
||||
withBlankDistroLike.Variants = []Variant{{
|
||||
Match: Match{DistroLike: []string{" "}},
|
||||
Argv: []string{"x"},
|
||||
}}
|
||||
|
||||
withEmptyArgv := plainCommand("a")
|
||||
withEmptyArgv.Variants = []Variant{{}}
|
||||
|
||||
noVariants := plainCommand("a")
|
||||
noVariants.Variants = nil
|
||||
|
||||
noSummary := plainCommand("a")
|
||||
noSummary.Summary = ""
|
||||
|
||||
noID := plainCommand("")
|
||||
noID.ID = ""
|
||||
|
||||
aliased := func(first, second []string) Registry {
|
||||
a, b := plainCommand("a"), plainCommand("b")
|
||||
a.Aliases, b.Aliases = first, second
|
||||
return Registry{Commands: []Command{a, b}}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
registry Registry
|
||||
wantErr string
|
||||
}{
|
||||
{"empty ID", Registry{Commands: []Command{noID}}, "empty ID"},
|
||||
{"empty summary", Registry{Commands: []Command{noSummary}}, "empty summary"},
|
||||
{"duplicate ID", Registry{Commands: []Command{plainCommand("a"), plainCommand("a")}}, "duplicate ID"},
|
||||
{"duplicate alias", aliased([]string{"z"}, []string{"z"}), "already used"},
|
||||
{"no variants", Registry{Commands: []Command{noVariants}}, "no variants"},
|
||||
{"empty argv", Registry{Commands: []Command{withEmptyArgv}}, "empty argv"},
|
||||
{"blank DistroLike", Registry{Commands: []Command{withBlankDistroLike}}, "empty DistroLike"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.registry.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error %q does not contain %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchIsComparable(t *testing.T) {
|
||||
a := Match{OS: "linux", Distro: "centos", DistroLike: []string{"rhel"}, Runtime: "docker"}
|
||||
b := Match{OS: "linux", Distro: "centos", DistroLike: []string{"rhel"}, Runtime: "docker"}
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Fatal("Match should be a comparable plain struct value")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user