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
+276
View File
@@ -0,0 +1,276 @@
// Package cli builds the kopsc command tree from the registry.
package cli
import (
"context"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/executor"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/resolve"
)
// App wires a registry and a detected environment to a cobra command tree.
type App struct {
Registry *registry.Registry
Info platform.Info
Version string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
}
// New returns an App for the running process.
func New(version string) *App {
return &App{
Registry: registry.Builtin(),
Info: platform.System().Detect(),
Version: version,
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
}
// Execute runs the command tree and returns the process exit code.
func (a *App) Execute(args []string) int {
root := a.Root()
root.SetArgs(args)
if err := root.Execute(); err != nil {
fmt.Fprintln(a.Stderr, "kopsc: "+err.Error())
return executor.ExitCode(err)
}
return 0
}
// Root builds the full cobra command tree.
func (a *App) Root() *cobra.Command {
root := &cobra.Command{
Use: "kopsc",
Short: "跨平台命令助手:用统一的意图命令在各平台执行对应命令",
Long: "kopsc 用平台无关的意图命令(如 docker clearsafe),根据操作系统、发行版与已安装的运行时,选择并执行对应的底层命令。",
Version: a.Version,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
root.SetOut(a.Stdout)
root.SetErr(a.Stderr)
root.SetIn(a.Stdin)
for i := range a.Registry.Commands {
a.addRegistryCommand(root, &a.Registry.Commands[i])
}
root.AddCommand(a.newListCmd(), a.newExplainCmd(), a.newPlatformCmd(), a.newVersionCmd())
return root
}
// addRegistryCommand turns "docker.clearsafe" into `kopsc docker clearsafe`,
// reusing intermediate commands for shared ID prefixes.
func (a *App) addRegistryCommand(root *cobra.Command, cmd *registry.Command) {
parts := strings.Split(cmd.ID, ".")
parent := root
for _, group := range parts[:len(parts)-1] {
child := findChild(parent, group)
if child == nil {
groupName := group
child = &cobra.Command{
Use: groupName,
Short: groupName + " 相关命令",
RunE: func(c *cobra.Command, args []string) error {
return c.Help()
},
}
parent.AddCommand(child)
}
parent = child
}
leaf := a.newLeaf(cmd, parts[len(parts)-1])
parent.AddCommand(leaf)
}
func findChild(parent *cobra.Command, name string) *cobra.Command {
for _, child := range parent.Commands() {
if child.Name() == name {
return child
}
}
return nil
}
func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command {
leaf := &cobra.Command{
Use: name + " [--dry-run] [-h] [-- 透传参数...]",
Short: cmd.Summary,
Long: cmd.Summary + "\n\n动作: " + cmd.Action,
DisableFlagParsing: true,
RunE: func(c *cobra.Command, args []string) error {
dryRun, extra, help := parseLeafArgs(args)
if help {
a.printLeafHelp(c.OutOrStdout(), cmd)
return nil
}
result, err := resolve.Resolve(cmd, a.Info)
if err != nil {
return fmt.Errorf("%w(可用 `kopsc explain %s` 查看全部候选)", err, cmd.ID)
}
plan := executor.Plan{Result: result, ExtraArgs: extra}
return plan.Run(context.Background(), executor.Options{
DryRun: dryRun,
Stdin: a.Stdin,
Stdout: a.Stdout,
Stderr: a.Stderr,
})
},
}
return leaf
}
// parseLeafArgs splits leaf arguments: kopsc-level flags before the first `--`
// are consumed, everything else (including `--` and after) is forwarded.
func parseLeafArgs(args []string) (dryRun bool, extra []string, help bool) {
passthrough := false
for _, arg := range args {
if passthrough {
extra = append(extra, arg)
continue
}
switch {
case arg == "--":
passthrough = true
case arg == "-n" || arg == "--dry-run" || arg == "--dry-run=true":
dryRun = true
case arg == "--dry-run=false":
dryRun = false
case arg == "-h" || arg == "--help":
help = true
default:
extra = append(extra, arg)
}
}
return dryRun, extra, help
}
func (a *App) printLeafHelp(w io.Writer, cmd *registry.Command) {
invocation := "kopsc " + strings.ReplaceAll(cmd.ID, ".", " ")
fmt.Fprintf(w, "用法: %s [--dry-run] [-h] [-- 透传参数...]\n\n", invocation)
fmt.Fprintf(w, "%s\n\n", cmd.Summary)
fmt.Fprintf(w, "动作: %s\n\n", cmd.Action)
fmt.Fprintf(w, "当前平台: %s\n", resolve.DescribeInfo(a.Info))
if result, err := resolve.Resolve(cmd, a.Info); err == nil {
fmt.Fprintf(w, "将执行: %s\n\n", strings.Join(result.Variant.Argv, " "))
} else {
fmt.Fprintf(w, "当前平台不可用: %v\n\n", err)
}
fmt.Fprint(w, "选项:\n")
fmt.Fprint(w, " -n, --dry-run 仅打印将执行的命令与判定依据,不执行\n")
fmt.Fprint(w, " -h, --help 显示本帮助\n")
fmt.Fprint(w, " -- 其后的参数原样透传给底层命令\n")
}
func (a *App) newListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "列出全部命令及其在当前平台的可用性",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
tw := tabwriter.NewWriter(a.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "命令\t状态\t将执行")
for i := range a.Registry.Commands {
entry := &a.Registry.Commands[i]
status, planned := "可用", "-"
if result, err := resolve.Resolve(entry, a.Info); err == nil {
planned = strings.Join(result.Variant.Argv, " ")
} else {
status = "不可用"
}
fmt.Fprintf(tw, "%s\t%s\t%s\n", entry.ID, status, planned)
}
return tw.Flush()
},
}
}
func (a *App) newExplainCmd() *cobra.Command {
return &cobra.Command{
Use: "explain <命令>",
Short: "解释某条命令在当前平台的匹配过程",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
entry, ok := a.Registry.Lookup(args[0])
if !ok {
return fmt.Errorf("未知命令 %q", args[0])
}
w := a.Stdout
fmt.Fprintf(w, "命令: %s\n", entry.ID)
fmt.Fprintf(w, "说明: %s\n", entry.Summary)
fmt.Fprintf(w, "动作: %s\n", entry.Action)
fmt.Fprintf(w, "平台: %s\n\n", resolve.DescribeInfo(a.Info))
result, err := resolve.Resolve(entry, a.Info)
if err != nil {
fmt.Fprintf(w, "结果: 当前平台不可用 — %v\n\n", err)
} else {
fmt.Fprintf(w, "结果: 命中变体 #%d(%d 分)→ %s\n\n",
result.VariantIdx, result.Score, strings.Join(result.Variant.Argv, " "))
}
fmt.Fprintln(w, "候选:")
for _, c := range result.Candidates {
mark := "✗"
if c.Matched {
mark = "✓"
}
note := ""
if c.Variant.Note != "" {
note = " # " + c.Variant.Note
}
fmt.Fprintf(w, " %s #%d %d 分 %s -> %s%s\n",
mark, c.Index, c.Score, c.Reason, strings.Join(c.Variant.Argv, " "), note)
}
return nil
},
}
}
func (a *App) newPlatformCmd() *cobra.Command {
return &cobra.Command{
Use: "platform",
Short: "显示探测到的当前平台信息",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
_, err := io.WriteString(a.Stdout, a.Info.String())
return err
},
}
}
func (a *App) newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "显示版本与平台",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
_, err := fmt.Fprintf(a.Stdout, "kopsc %s %s/%s\n", a.Version, a.Info.OS, a.Info.Arch)
return err
},
}
}
+268
View File
@@ -0,0 +1,268 @@
package cli
import (
"bytes"
"os"
"strings"
"testing"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
)
func centos(runtimes ...string) platform.Info {
return platform.Info{
OS: "linux", Arch: "amd64",
Distro: "centos", DistroLike: []string{"rhel", "fedora"},
Runtimes: runtimes,
}
}
func testApp(info platform.Info) (*App, *bytes.Buffer, *bytes.Buffer) {
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
return &App{
Registry: registry.Builtin(),
Info: info,
Version: "test",
Stdin: strings.NewReader(""),
Stdout: stdout,
Stderr: stderr,
}, stdout, stderr
}
func TestLeafDryRun(t *testing.T) {
app, stdout, stderr := testApp(centos("docker"))
code := app.Execute([]string{"docker", "clearsafe", "--dry-run"})
if code != 0 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
}
out := stdout.String()
for _, want := range []string{"[dry-run]", "docker system prune -f", "CentOS 上的 Docker 运行时", "依据"} {
if !strings.Contains(out, want) {
t.Errorf("dry-run output missing %q:\n%s", want, out)
}
}
}
func TestLeafDryRunPrefersPodman(t *testing.T) {
app, stdout, _ := testApp(centos("docker", "podman"))
if code := app.Execute([]string{"docker", "clearsafe", "--dry-run"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
if !strings.Contains(stdout.String(), "podman system prune -f") {
t.Errorf("expected podman variant:\n%s", stdout.String())
}
if strings.Contains(stdout.String(), "docker system prune -f") {
t.Errorf("docker variant should not be chosen when podman is present:\n%s", stdout.String())
}
}
func TestLeafHelp(t *testing.T) {
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"docker", "clearsafe", "-h"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
out := stdout.String()
for _, want := range []string{"用法: kopsc docker clearsafe", "安全清理", "--dry-run", "将执行: docker system prune -f"} {
if !strings.Contains(out, want) {
t.Errorf("help output missing %q:\n%s", want, out)
}
}
}
func TestLeafPassesThroughArgs(t *testing.T) {
app, stdout, _ := testApp(centos("docker"))
code := app.Execute([]string{"net", "ports", "--dry-run", "--", "-l"})
if code != 0 {
t.Fatalf("exit code = %d", code)
}
if !strings.Contains(stdout.String(), "ss -tulnp -l") {
t.Errorf("passthrough args missing:\n%s", stdout.String())
}
}
func TestLeafNoMatch(t *testing.T) {
app, _, stderr := testApp(centos()) // no container runtime
code := app.Execute([]string{"docker", "clearsafe"})
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "不支持当前平台") {
t.Errorf("stderr lacks explanation:\n%s", stderr.String())
}
if !strings.Contains(stderr.String(), "kopsc explain") {
t.Errorf("stderr lacks hint:\n%s", stderr.String())
}
}
func TestList(t *testing.T) {
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"list"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
out := stdout.String()
for _, want := range []string{"docker.clearsafe", "net.ports", "pkg.refreshindex", "可用", "docker system prune -f"} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q:\n%s", want, out)
}
}
}
func TestListMarksUnavailable(t *testing.T) {
app, stdout, _ := testApp(platform.Info{OS: "plan9", Arch: "mips"})
if code := app.Execute([]string{"list"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
if !strings.Contains(stdout.String(), "不可用") {
t.Errorf("unavailable commands not marked:\n%s", stdout.String())
}
}
func TestExplain(t *testing.T) {
app, stdout, _ := testApp(centos("docker", "podman"))
if code := app.Execute([]string{"explain", "docker.clearsafe"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
out := stdout.String()
for _, want := range []string{
"命令: docker.clearsafe",
"平台:",
"命中变体 #0",
"候选:",
"✓",
"✗",
"podman system prune -f",
"distro=centos",
} {
if !strings.Contains(out, want) {
t.Errorf("explain output missing %q:\n%s", want, out)
}
}
}
func TestExplainUnavailableStillListsCandidates(t *testing.T) {
app, stdout, _ := testApp(centos()) // no runtime
if code := app.Execute([]string{"explain", "docker.clearsafe"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
out := stdout.String()
if !strings.Contains(out, "当前平台不可用") {
t.Errorf("missing unavailable marker:\n%s", out)
}
if strings.Count(out, "✗") != len(registry.Builtin().Commands[0].Variants) {
t.Errorf("expected every candidate to be listed:\n%s", out)
}
}
func TestExplainUnknownCommand(t *testing.T) {
app, _, stderr := testApp(centos("docker"))
if code := app.Execute([]string{"explain", "nope"}); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "未知命令") {
t.Errorf("stderr = %s", stderr.String())
}
}
func TestPlatform(t *testing.T) {
app, stdout, _ := testApp(centos("docker", "podman"))
if code := app.Execute([]string{"platform"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
out := stdout.String()
for _, want := range []string{"linux", "amd64", "centos", "rhel fedora", "docker, podman"} {
if !strings.Contains(out, want) {
t.Errorf("platform output missing %q:\n%s", want, out)
}
}
}
func TestVersion(t *testing.T) {
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"version"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
if !strings.Contains(stdout.String(), "kopsc test linux/amd64") {
t.Errorf("version output = %s", stdout.String())
}
}
func TestGroupHelp(t *testing.T) {
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"docker"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
if !strings.Contains(stdout.String(), "clearsafe") {
t.Errorf("group help missing subcommand:\n%s", stdout.String())
}
}
func TestParseLeafArgs(t *testing.T) {
tests := []struct {
name string
args []string
wantDryRun bool
wantExtra []string
wantHelp bool
}{
{"empty", nil, false, nil, false},
{"dry-run short", []string{"-n"}, true, nil, false},
{"dry-run long", []string{"--dry-run"}, true, nil, false},
{"dry-run false", []string{"--dry-run=false"}, false, nil, false},
{"help", []string{"-h"}, false, nil, true},
{"passthrough", []string{"--", "--dry-run", "-h"}, false, []string{"--dry-run", "-h"}, false},
{"unknown flags forwarded", []string{"--filter", "until=1h"}, false, []string{"--filter", "until=1h"}, false},
{"mixed", []string{"--dry-run", "arg", "--", "-n"}, true, []string{"arg", "-n"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dryRun, extra, help := parseLeafArgs(tc.args)
if dryRun != tc.wantDryRun || help != tc.wantHelp {
t.Errorf("dryRun=%v help=%v, want %v/%v", dryRun, help, tc.wantDryRun, tc.wantHelp)
}
if strings.Join(extra, "|") != strings.Join(tc.wantExtra, "|") {
t.Errorf("extra = %v, want %v", extra, tc.wantExtra)
}
})
}
}
// TestCLIHelperProcess is re-executed as a child to verify exit-code passthrough.
func TestCLIHelperProcess(t *testing.T) {
if os.Getenv("KOPSC_CLI_HELPER") != "1" {
return
}
os.Exit(9)
}
func TestExecutionPreservesExitCode(t *testing.T) {
t.Setenv("KOPSC_CLI_HELPER", "1")
app, _, _ := testApp(centos("docker"))
app.Registry = &registry.Registry{Commands: []registry.Command{{
ID: "demo.fail",
Summary: "always fails",
Variants: []registry.Variant{{
Match: registry.Match{OS: "linux"},
Argv: []string{os.Args[0], "-test.run=TestCLIHelperProcess"},
}},
}}}
if code := app.Execute([]string{"demo", "fail"}); code != 9 {
t.Fatalf("exit code = %d, want 9", code)
}
}