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,187 @@
|
||||
// Package executor turns a resolved variant into a process, with dry-run
|
||||
// support and faithful stdio/exit-code passthrough.
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/resolve"
|
||||
)
|
||||
|
||||
// Options controls how a Plan is executed.
|
||||
type Options struct {
|
||||
DryRun bool
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Env []string // optional override of the child environment
|
||||
Dir string // optional working directory
|
||||
}
|
||||
|
||||
// Plan is a resolved variant plus arguments forwarded from the caller.
|
||||
type Plan struct {
|
||||
Result resolve.Result
|
||||
ExtraArgs []string
|
||||
}
|
||||
|
||||
// Argv returns the final argument vector: the variant's argv followed by the
|
||||
// forwarded arguments.
|
||||
func (p Plan) Argv() []string {
|
||||
argv := make([]string, 0, len(p.Result.Variant.Argv)+len(p.ExtraArgs))
|
||||
argv = append(argv, p.Result.Variant.Argv...)
|
||||
argv = append(argv, p.ExtraArgs...)
|
||||
return argv
|
||||
}
|
||||
|
||||
// Display renders the command line the plan would run.
|
||||
func (p Plan) Display() string {
|
||||
argv := p.Argv()
|
||||
if p.Result.Variant.Shell {
|
||||
flag := "-c"
|
||||
if runtime.GOOS == "windows" {
|
||||
flag = "/c"
|
||||
}
|
||||
return shellName() + " " + flag + " " + quoteForShell(strings.Join(argv, " "), runtime.GOOS)
|
||||
}
|
||||
return strings.Join(argv, " ")
|
||||
}
|
||||
|
||||
// Explain writes why this variant was chosen and what will run.
|
||||
func (p Plan) Explain(w io.Writer) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
variant := p.Result.Variant
|
||||
fmt.Fprintf(w, "将执行: %s\n", p.Display())
|
||||
fmt.Fprintf(w, "平台: %s\n", resolve.DescribeInfo(p.Result.Info))
|
||||
if variant.Shell {
|
||||
fmt.Fprintf(w, "方式: shell (%s)\n", shellName())
|
||||
} else {
|
||||
fmt.Fprintf(w, "方式: 直接执行\n")
|
||||
}
|
||||
note := variant.Note
|
||||
if note == "" {
|
||||
note = "无备注"
|
||||
}
|
||||
fmt.Fprintf(w, "命中: 变体 #%d(%d 分)%s\n", p.Result.VariantIdx, p.Result.Score, note)
|
||||
if p.Result.VariantIdx >= 0 && p.Result.VariantIdx < len(p.Result.Candidates) {
|
||||
fmt.Fprintf(w, "依据: %s\n", p.Result.Candidates[p.Result.VariantIdx].Reason)
|
||||
}
|
||||
if len(p.ExtraArgs) > 0 {
|
||||
fmt.Fprintf(w, "透传: %s\n", strings.Join(p.ExtraArgs, " "))
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the plan. With DryRun it only prints the explanation. A child
|
||||
// exiting non-zero is reported as *ExitError so callers can preserve the code.
|
||||
func (p Plan) Run(ctx context.Context, opts Options) error {
|
||||
if opts.Stdout == nil {
|
||||
opts.Stdout = io.Discard
|
||||
}
|
||||
if opts.Stderr == nil {
|
||||
opts.Stderr = io.Discard
|
||||
}
|
||||
if opts.DryRun {
|
||||
fmt.Fprintln(opts.Stdout, "[dry-run] 未执行任何命令")
|
||||
p.Explain(opts.Stdout)
|
||||
return nil
|
||||
}
|
||||
|
||||
name, args := p.command()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdin = opts.Stdin
|
||||
cmd.Stdout = opts.Stdout
|
||||
cmd.Stderr = opts.Stderr
|
||||
cmd.Env = opts.Env
|
||||
cmd.Dir = opts.Dir
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return &ExitError{Code: exitErr.ExitCode(), Err: err}
|
||||
}
|
||||
return fmt.Errorf("执行 %q 失败: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Plan) command() (string, []string) {
|
||||
argv := p.Argv()
|
||||
if !p.Result.Variant.Shell {
|
||||
return argv[0], argv[1:]
|
||||
}
|
||||
shell := shellName()
|
||||
if runtime.GOOS == "windows" {
|
||||
return shell, []string{"/c", joinForShell(argv, runtime.GOOS)}
|
||||
}
|
||||
return shell, []string{"-c", joinForShell(argv, runtime.GOOS)}
|
||||
}
|
||||
|
||||
func shellName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "cmd"
|
||||
}
|
||||
return "sh"
|
||||
}
|
||||
|
||||
// joinForShell joins argv with platform-appropriate quoting.
|
||||
func joinForShell(argv []string, osName string) string {
|
||||
parts := make([]string, len(argv))
|
||||
for i, arg := range argv {
|
||||
parts[i] = quoteForShell(arg, osName)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// quoteForShell leaves safe arguments untouched and quotes the rest.
|
||||
func quoteForShell(arg, osName string) string {
|
||||
if osName == "windows" {
|
||||
if arg != "" && !strings.ContainsAny(arg, " \t\"&|<>^%") {
|
||||
return arg
|
||||
}
|
||||
return `"` + strings.ReplaceAll(arg, `"`, `\"`) + `"`
|
||||
}
|
||||
|
||||
if arg != "" && strings.IndexFunc(arg, unsafeShellRune) < 0 {
|
||||
return arg
|
||||
}
|
||||
return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
func unsafeShellRune(r rune) bool {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
return false
|
||||
}
|
||||
return !strings.ContainsRune("-_.:/=@+,", r)
|
||||
}
|
||||
|
||||
// ExitError carries the child process exit code.
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string {
|
||||
return fmt.Sprintf("命令以状态码 %d 退出", e.Code)
|
||||
}
|
||||
|
||||
func (e *ExitError) Unwrap() error { return e.Err }
|
||||
|
||||
// ExitCode maps an error to the process exit code kopsc should return.
|
||||
func ExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.Code
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/resolve"
|
||||
)
|
||||
|
||||
func samplePlan(argv ...string) Plan {
|
||||
if len(argv) == 0 {
|
||||
argv = []string{"docker", "system", "prune", "-f"}
|
||||
}
|
||||
return Plan{Result: resolve.Result{
|
||||
Command: ®istry.Command{ID: "docker.clearsafe", Summary: "安全清理"},
|
||||
Info: platform.Info{
|
||||
OS: "linux", Arch: "amd64", Distro: "centos",
|
||||
DistroLike: []string{"rhel"}, Runtimes: []string{"docker"},
|
||||
},
|
||||
Variant: registry.Variant{Argv: argv, Note: "CentOS 上的 Docker 运行时"},
|
||||
VariantIdx: 0,
|
||||
Score: 5,
|
||||
Candidates: []resolve.Candidate{{
|
||||
Index: 0, Matched: true, Score: 5,
|
||||
Reason: "匹配 os=linux, distro=centos (+3), runtime=docker (+2)",
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestDryRunOnlyPrints(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
err := samplePlan().Run(context.Background(), Options{DryRun: true, Stdout: &out})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run returned error: %v", err)
|
||||
}
|
||||
|
||||
text := out.String()
|
||||
for _, want := range []string{
|
||||
"[dry-run]",
|
||||
"docker system prune -f",
|
||||
"linux/amd64",
|
||||
"distro=centos",
|
||||
"变体 #0",
|
||||
"CentOS 上的 Docker 运行时",
|
||||
"依据",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("dry-run output missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunNeverExecutes(t *testing.T) {
|
||||
// A missing program must not matter in dry-run mode.
|
||||
err := samplePlan("kopsc-program-that-does-not-exist").Run(context.Background(), Options{
|
||||
DryRun: true,
|
||||
Stdout: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run should not attempt execution: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgvAppendsExtraArgs(t *testing.T) {
|
||||
plan := samplePlan("docker", "system", "prune", "-f")
|
||||
plan.ExtraArgs = []string{"--filter", "until=24h"}
|
||||
want := []string{"docker", "system", "prune", "-f", "--filter", "until=24h"}
|
||||
if got := plan.Argv(); strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("Argv() = %v, want %v", got, want)
|
||||
}
|
||||
if !strings.Contains(plan.Display(), "--filter until=24h") {
|
||||
t.Fatalf("Display() = %q", plan.Display())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHelperProcess is re-executed as a child by the tests below.
|
||||
func TestHelperProcess(t *testing.T) {
|
||||
if os.Getenv("KOPSC_TEST_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "helper-stdout", strings.Join(os.Args[1:], " "))
|
||||
fmt.Fprintln(os.Stderr, "helper-stderr")
|
||||
os.Exit(7)
|
||||
}
|
||||
|
||||
func TestRunPropagatesStdioAndExitCode(t *testing.T) {
|
||||
t.Setenv("KOPSC_TEST_HELPER", "1")
|
||||
|
||||
plan := samplePlan(os.Args[0], "-test.run=TestHelperProcess", "--")
|
||||
plan.ExtraArgs = []string{"forwarded"}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := plan.Run(context.Background(), Options{Stdout: &stdout, Stderr: &stderr})
|
||||
if err == nil {
|
||||
t.Fatal("expected a non-zero exit error")
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *ExitError", err)
|
||||
}
|
||||
if exitErr.Code != 7 {
|
||||
t.Errorf("exit code = %d, want 7", exitErr.Code)
|
||||
}
|
||||
if ExitCode(err) != 7 {
|
||||
t.Errorf("ExitCode(err) = %d, want 7", ExitCode(err))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "helper-stdout") {
|
||||
t.Errorf("child stdout not forwarded:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "forwarded") {
|
||||
t.Errorf("extra args not forwarded:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "helper-stderr") {
|
||||
t.Errorf("child stderr not forwarded:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFindsProgramOnPATH(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell script fixture is not executable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "docker")
|
||||
body := "#!/bin/sh\necho fake-docker \"$@\"\nexit 3\n"
|
||||
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
var out bytes.Buffer
|
||||
err := samplePlan().Run(context.Background(), Options{Stdout: &out, Stderr: io.Discard})
|
||||
if ExitCode(err) != 3 {
|
||||
t.Fatalf("exit code = %d, want 3 (err=%v)", ExitCode(err), err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "fake-docker system prune -f") {
|
||||
t.Errorf("fixture output missing:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNotFound(t *testing.T) {
|
||||
err := samplePlan("kopsc-program-that-does-not-exist").Run(context.Background(), Options{
|
||||
Stdout: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a missing program")
|
||||
}
|
||||
if ExitCode(err) != 1 {
|
||||
t.Errorf("ExitCode() = %d, want 1", ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellVariant(t *testing.T) {
|
||||
plan := samplePlan("echo", "hello from shell")
|
||||
plan.Result.Variant.Shell = true
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := plan.Run(context.Background(), Options{Stdout: &out, Stderr: io.Discard}); err != nil {
|
||||
t.Fatalf("shell run failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "hello from shell") {
|
||||
t.Errorf("shell output missing:\n%s", out.String())
|
||||
}
|
||||
|
||||
display := plan.Display()
|
||||
wantShell := "sh -c"
|
||||
if runtime.GOOS == "windows" {
|
||||
wantShell = "cmd /c"
|
||||
}
|
||||
if !strings.HasPrefix(display, wantShell) {
|
||||
t.Errorf("Display() = %q, want prefix %q", display, wantShell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteForShell(t *testing.T) {
|
||||
unixCases := map[string]string{
|
||||
"plain": "plain",
|
||||
"with space": "'with space'",
|
||||
"it's": `'it'\''s'`,
|
||||
"": "''",
|
||||
"/usr/bin/x": "/usr/bin/x",
|
||||
"--filter=a,b": "--filter=a,b",
|
||||
}
|
||||
for in, want := range unixCases {
|
||||
if got := quoteForShell(in, "linux"); got != want {
|
||||
t.Errorf("quoteForShell(%q, linux) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
windowsCases := map[string]string{
|
||||
"plain": "plain",
|
||||
"with space": `"with space"`,
|
||||
}
|
||||
for in, want := range windowsCases {
|
||||
if got := quoteForShell(in, "windows"); got != want {
|
||||
t.Errorf("quoteForShell(%q, windows) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCode(t *testing.T) {
|
||||
if got := ExitCode(nil); got != 0 {
|
||||
t.Errorf("ExitCode(nil) = %d, want 0", got)
|
||||
}
|
||||
if got := ExitCode(errors.New("boom")); got != 1 {
|
||||
t.Errorf("ExitCode(generic) = %d, want 1", got)
|
||||
}
|
||||
if got := ExitCode(&ExitError{Code: 42}); got != 42 {
|
||||
t.Errorf("ExitCode(ExitError) = %d, want 42", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user