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
+194
View File
@@ -0,0 +1,194 @@
// Command build cross-compiles kopsc for the supported platforms.
//
// Usage:
//
// go run ./scripts/build # every supported target
// go run ./scripts/build -host # current OS/arch only
// go run ./scripts/build -targets linux/amd64,windows/arm64
// go run ./scripts/build -version 1.2.3 -out bin
//
// Output names are kopsc-<version>-<os>-<arch>[.exe] under -out (default: dist).
package main
import (
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
type target struct {
os string
arch string
}
// defaultTargets is the release matrix.
var defaultTargets = []target{
{"linux", "amd64"},
{"linux", "arm64"},
{"darwin", "amd64"},
{"darwin", "arm64"},
{"windows", "amd64"},
{"windows", "arm64"},
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "build: "+err.Error())
os.Exit(1)
}
}
func run(args []string) error {
fs := flag.NewFlagSet("build", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var (
outDir = fs.String("out", "dist", "output directory")
versionArg = fs.String("version", "", "version string (default: git describe --tags --always --dirty)")
targetArg = fs.String("targets", "", "comma-separated os/arch list, e.g. linux/amd64,windows/arm64")
hostOnly = fs.Bool("host", false, "build only the current os/arch")
noStrip = fs.Bool("no-strip", false, "keep symbols (do not pass -s -w)")
quiet = fs.Bool("quiet", false, "only print errors")
)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() > 0 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " "))
}
var (
targets []target
err error
)
switch {
case *hostOnly && *targetArg != "":
return errors.New("-host and -targets cannot be used together")
case *hostOnly:
targets = []target{{runtime.GOOS, runtime.GOARCH}}
default:
targets, err = parseTargets(*targetArg)
if err != nil {
return err
}
}
version := *versionArg
if version == "" {
version = detectVersion()
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
return fmt.Errorf("create output directory: %w", err)
}
logf := func(format string, a ...any) {
if !*quiet {
fmt.Printf(format, a...)
}
}
logf("kopsc %s -> %s/\n", version, filepath.ToSlash(*outDir))
for _, t := range targets {
path, size, err := build(t, version, *outDir, !*noStrip)
if err != nil {
return fmt.Errorf("%s/%s: %w", t.os, t.arch, err)
}
logf(" %-8s %-6s %-42s %6.1f MiB\n", t.os, t.arch, path, float64(size)/(1<<20))
}
logf("done: %d target(s)\n", len(targets))
return nil
}
// parseTargets parses "os/arch,..." and falls back to the release matrix.
func parseTargets(spec string) ([]target, error) {
if strings.TrimSpace(spec) == "" {
return defaultTargets, nil
}
var targets []target
seen := make(map[target]bool)
for _, item := range strings.Split(spec, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
osName, arch, ok := strings.Cut(item, "/")
osName, arch = strings.TrimSpace(osName), strings.TrimSpace(arch)
if !ok || osName == "" || arch == "" {
return nil, fmt.Errorf("invalid target %q, want os/arch such as linux/amd64", item)
}
t := target{osName, arch}
if seen[t] {
continue
}
seen[t] = true
targets = append(targets, t)
}
if len(targets) == 0 {
return nil, errors.New("no targets to build")
}
return targets, nil
}
// artifactName returns the file name for a target.
func artifactName(version string, t target) string {
name := fmt.Sprintf("kopsc-%s-%s-%s", version, t.os, t.arch)
if t.os == "windows" {
name += ".exe"
}
return name
}
// build compiles one target and reports the artifact path and size.
func build(t target, version, outDir string, strip bool) (string, int64, error) {
path := filepath.Join(outDir, artifactName(version, t))
ldflags := "-X main.version=" + version
if strip {
ldflags = "-s -w " + ldflags
}
cmd := exec.Command("go", "build",
"-trimpath",
"-ldflags", ldflags,
"-o", path,
"./cmd/kopsc",
)
cmd.Env = append(os.Environ(),
"CGO_ENABLED=0",
"GOOS="+t.os,
"GOARCH="+t.arch,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", 0, fmt.Errorf("go build failed: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return "", 0, fmt.Errorf("stat artifact: %w", err)
}
return path, info.Size(), nil
}
// detectVersion asks git for a version, falling back to "dev".
func detectVersion() string {
out, err := exec.Command("git", "describe", "--tags", "--always", "--dirty").Output()
if err != nil {
return "dev"
}
version := strings.TrimSpace(string(out))
if version == "" {
return "dev"
}
return version
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"reflect"
"strings"
"testing"
)
func TestParseTargetsDefault(t *testing.T) {
got, err := parseTargets("")
if err != nil {
t.Fatalf("parseTargets(\"\") error: %v", err)
}
if !reflect.DeepEqual(got, defaultTargets) {
t.Fatalf("got %v, want default matrix %v", got, defaultTargets)
}
}
func TestParseTargetsCustom(t *testing.T) {
got, err := parseTargets(" linux/amd64 , windows/arm64 ,linux/amd64,")
if err != nil {
t.Fatalf("parseTargets error: %v", err)
}
want := []target{{"linux", "amd64"}, {"windows", "arm64"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestParseTargetsInvalid(t *testing.T) {
for _, spec := range []string{"linux", "/amd64", "linux/", " , "} {
if _, err := parseTargets(spec); err == nil {
t.Errorf("parseTargets(%q) should fail", spec)
}
}
}
func TestArtifactName(t *testing.T) {
cases := []struct {
version string
t target
want string
}{
{"1.2.3", target{"linux", "amd64"}, "kopsc-1.2.3-linux-amd64"},
{"v1.2.3-2-gabc-dirty", target{"darwin", "arm64"}, "kopsc-v1.2.3-2-gabc-dirty-darwin-arm64"},
{"dev", target{"windows", "amd64"}, "kopsc-dev-windows-amd64.exe"},
}
for _, tc := range cases {
if got := artifactName(tc.version, tc.t); got != tc.want {
t.Errorf("artifactName(%q, %v) = %q, want %q", tc.version, tc.t, got, tc.want)
}
}
}
func TestRunRejectsConflictingFlags(t *testing.T) {
err := run([]string{"-host", "-targets", "linux/amd64"})
if err == nil || !strings.Contains(err.Error(), "cannot be used together") {
t.Fatalf("err = %v, want conflict error", err)
}
}