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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user