diff --git a/README.md b/README.md index 92b865f..75016a4 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,37 @@ kopsc docker clearsafe --dry-run # 只打印将执行的命令与判定依据 kopsc docker clearsafe -h # 该意图的说明、当前平台命中结果与选项 kopsc docker clearsafe -- --filter until=24h # -- 之后的参数原样透传给底层命令 +kopsc disk usage /var # 磁盘占用排行(du -x --max-depth=1 -h | sort -hr 的跨平台版) +kopsc disk usage --path /var # 同上,用命名参数指定路径 +kopsc disk usage /var --dry-run # 预览(内置命令同样支持) + kopsc list # 全部命令及当前平台可用性 kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因 kopsc platform # 探测到的 OS/ARCH/发行版/运行时 kopsc version # 版本与平台 ``` +### 磁盘占用排行(disk.usage) + +`kopsc disk usage <路径>` 等价于 `du -x --max-depth=1 -h <路径> | sort -hr`,但由 kopsc +内置实现,因此 Linux、macOS、Windows 输出完全一致,不依赖 `du`、`sort` 或 PowerShell: + +```text +$ kopsc disk usage /var +1.2G /var/lib +800.0M /var/cache +4.0K /var/tmp +0 /var/opt +总计 2.0G /var +``` + +- 路径**必填**:位置参数(`/var`)或 `--path /var`、`--path=/var` 均可,一次一个。 +- 统计每个直接子项:普通文件按自身大小,目录递归累计其下普通文件的大小。 +- 不跟随符号链接;Unix 上通过设备号实现 `-x`(不跨文件系统),Windows 上不跟随 + reparse point,目录遍历本身不会跨越卷。 +- 输出按大小降序,大小采用与 `du -h` 相同的二进制单位(1.0K、1.5M、2.0G)。 +- 末尾输出该路径的合计行。 + ### 选项解析规则 叶子命令禁用了 cobra 的 flag 解析,自行处理: @@ -79,6 +104,7 @@ kopsc version # 版本与平台 | 命令 | 说明 | 平台变体 | | --- | --- | --- | | `docker.clearsafe` | 安全清理容器/网络/构建缓存 | Linux(centos)+podman、Linux(centos)+docker、Linux+podman、Linux+docker、darwin+docker、windows+docker,均为 `system prune -f` | +| `disk.usage` | 目录占用排行(等价 `du -x --max-depth=1 -h <路径> \| sort -hr`) | Linux/macOS/Windows 均使用 kopsc 内置实现,保证输出一致 | | `net.ports` | 列出监听中的端口 | Linux `ss -tulnp`、macOS `lsof -iTCP -sTCP:LISTEN -nP`、Windows `netstat -ano` | | `pkg.refreshindex` | 刷新包管理器索引 | centos `dnf makecache`、ID_LIKE rhel/fedora `dnf makecache`、ID_LIKE debian/ubuntu `apt-get update`、alpine `apk update`、macOS `brew update`、Windows `winget source update` | @@ -127,6 +153,9 @@ kopsc version # 版本与平台 ``` - `Argv[0]` 是程序,其余是参数;需要 shell 语义时设 `Shell: true`(Linux/macOS 用 `sh -c`,Windows 用 `cmd /c`)。 +- 当平台没有可移植的系统命令时,用 `Builtin` 代替 `Argv`(二者互斥): + `Builtin: "disk.usage"` 指向 [`internal/executor`](internal/executor) 中注册的实现, + 这样三平台共用同一份逻辑。新增内置动作需在 `executor.builtins` 中注册。 - 新增命令时给 `Command` 填 `ID`(点号分隔,决定 CLI 层级)、`Summary`、`Action`,别名不得重复。 - 提交前跑 `go test ./...`:`registry.Validate()` 与快照测试会拦截重复 ID/别名、空变体、空 argv。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index fd506d9..3af5323 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -111,6 +111,14 @@ func findChild(parent *cobra.Command, name string) *cobra.Command { return nil } +// variantDisplay renders how a variant is executed, for list/help/explain. +func variantDisplay(v registry.Variant) string { + if v.Builtin != "" { + return "(内置) " + v.Builtin + } + return strings.Join(v.Argv, " ") +} + func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command { leaf := &cobra.Command{ Use: name + " [--dry-run] [-h] [-- 透传参数...]", @@ -174,7 +182,7 @@ func (a *App) printLeafHelp(w io.Writer, cmd *registry.Command) { 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, " ")) + fmt.Fprintf(w, "将执行: %s\n\n", variantDisplay(result.Variant)) } else { fmt.Fprintf(w, "当前平台不可用: %v\n\n", err) } @@ -197,7 +205,7 @@ func (a *App) newListCmd() *cobra.Command { entry := &a.Registry.Commands[i] status, planned := "可用", "-" if result, err := resolve.Resolve(entry, a.Info); err == nil { - planned = strings.Join(result.Variant.Argv, " ") + planned = variantDisplay(result.Variant) } else { status = "不可用" } @@ -230,7 +238,7 @@ func (a *App) newExplainCmd() *cobra.Command { 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, " ")) + result.VariantIdx, result.Score, variantDisplay(result.Variant)) } fmt.Fprintln(w, "候选:") @@ -244,7 +252,7 @@ func (a *App) newExplainCmd() *cobra.Command { 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) + mark, c.Index, c.Score, c.Reason, variantDisplay(c.Variant), note) } return nil }, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 5b1eece..dc33ba0 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "os" + "path/filepath" "strings" "testing" @@ -241,6 +242,80 @@ func TestParseLeafArgs(t *testing.T) { } } +func TestDiskUsageCommandThroughCLI(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a.bin"), bytes.Repeat([]byte("x"), 4096), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "sub", "b.bin"), bytes.Repeat([]byte("x"), 1024), 0o644); err != nil { + t.Fatal(err) + } + + app, stdout, stderr := testApp(centos("docker")) + if code := app.Execute([]string{"disk", "usage", root}); code != 0 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr.String()) + } + out := stdout.String() + for _, want := range []string{"4.0K", "1.0K", "a.bin", "sub", "总计 " + root} { + if !strings.Contains(out, want) { + t.Errorf("disk usage output missing %q:\n%s", want, out) + } + } +} + +func TestDiskUsageAcceptsPathFlagThroughCLI(t *testing.T) { + root := t.TempDir() + + app, stdout, stderr := testApp(centos("docker")) + if code := app.Execute([]string{"disk", "usage", "--path", root}); code != 0 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "总计 "+root) { + t.Errorf("unexpected output:\n%s", stdout.String()) + } +} + +func TestDiskUsageRequiresPathThroughCLI(t *testing.T) { + app, _, stderr := testApp(centos("docker")) + + if code := app.Execute([]string{"disk", "usage"}); code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "必须提供路径") { + t.Errorf("stderr = %s", stderr.String()) + } +} + +func TestDiskUsageDryRunDoesNotTouchPath(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + code := app.Execute([]string{"disk", "usage", "/path/that/does/not/exist", "--dry-run"}) + if code != 0 { + t.Fatalf("exit code = %d", code) + } + out := stdout.String() + if !strings.Contains(out, "(内置) disk.usage /path/that/does/not/exist") { + t.Errorf("dry-run output missing builtin plan:\n%s", out) + } + if strings.Contains(out, "无法访问") { + t.Errorf("dry-run should not execute the builtin:\n%s", out) + } +} + +func TestDiskUsageHelp(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + if code := app.Execute([]string{"disk", "usage", "-h"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + if !strings.Contains(stdout.String(), "(内置) disk.usage") { + t.Errorf("help output missing builtin target:\n%s", stdout.String()) + } +} + // TestCLIHelperProcess is re-executed as a child to verify exit-code passthrough. func TestCLIHelperProcess(t *testing.T) { if os.Getenv("KOPSC_CLI_HELPER") != "1" { diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go new file mode 100644 index 0000000..4630887 --- /dev/null +++ b/internal/executor/builtin.go @@ -0,0 +1,37 @@ +package executor + +import ( + "context" + "io" +) + +// BuiltinRequest carries the caller's arguments and output streams to an +// in-process command implementation. +type BuiltinRequest struct { + Args []string + Stdout io.Writer + Stderr io.Writer +} + +// Builtin executes a command inside kopsc instead of spawning a child. It is +// used where no portable system utility exists (for example du on Windows). +type Builtin func(ctx context.Context, req BuiltinRequest) error + +var builtins = map[string]Builtin{ + "disk.usage": runDiskUsage, +} + +// LookupBuiltin returns the implementation registered under name. +func LookupBuiltin(name string) (Builtin, bool) { + fn, ok := builtins[name] + return fn, ok +} + +// BuiltinNames lists the registered builtin implementations. +func BuiltinNames() []string { + names := make([]string, 0, len(builtins)) + for name := range builtins { + names = append(names, name) + } + return names +} diff --git a/internal/executor/device_unix.go b/internal/executor/device_unix.go new file mode 100644 index 0000000..edac3eb --- /dev/null +++ b/internal/executor/device_unix.go @@ -0,0 +1,26 @@ +//go:build unix + +package executor + +import ( + "os" + "syscall" +) + +// deviceOf returns the device ID backing a file, used to implement du's -x +// (do not cross filesystems). +func deviceOf(info os.FileInfo) (uint64, bool) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, false + } + return uint64(stat.Dev), true +} + +func deviceOfPath(path string) (uint64, bool) { + info, err := os.Stat(path) + if err != nil { + return 0, false + } + return deviceOf(info) +} diff --git a/internal/executor/device_windows.go b/internal/executor/device_windows.go new file mode 100644 index 0000000..e83a186 --- /dev/null +++ b/internal/executor/device_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package executor + +import "os" + +// Windows has no stable per-file device ID in os.FileInfo. Directory walks do +// not cross volumes on their own, so -x degrades to "do not follow reparse +// points", which filepath.WalkDir already guarantees. +func deviceOf(os.FileInfo) (uint64, bool) { return 0, false } + +func deviceOfPath(string) (uint64, bool) { return 0, false } diff --git a/internal/executor/diskusage.go b/internal/executor/diskusage.go new file mode 100644 index 0000000..58d5e2e --- /dev/null +++ b/internal/executor/diskusage.go @@ -0,0 +1,188 @@ +package executor + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// runDiskUsage implements `du -x --max-depth=1 -h | sort -hr`. +// +// It reports every direct child of the given directory: regular files by their +// size, directories by the recursive size of their regular files. Symbolic +// links are not followed and, where the platform exposes device IDs, nested +// filesystems are not crossed (-x). +func runDiskUsage(ctx context.Context, req BuiltinRequest) error { + paths, err := parseDiskUsageArgs(req.Args) + if err != nil { + return err + } + root := paths[0] + + rootInfo, err := os.Stat(root) + if err != nil { + return fmt.Errorf("无法访问 %q: %w", root, err) + } + if !rootInfo.IsDir() { + return fmt.Errorf("%q 不是目录", root) + } + + entries, err := os.ReadDir(root) + if err != nil { + return fmt.Errorf("读取目录 %q 失败: %w", root, err) + } + + rootDev, checkDev := deviceOf(rootInfo) + + type entrySize struct { + path string + size int64 + } + items := make([]entrySize, 0, len(entries)) + var total int64 + + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return err + } + path := filepath.Join(root, entry.Name()) + info, err := entry.Info() + if err != nil { + // Unreadable entries are skipped rather than failing the whole run. + continue + } + + var size int64 + switch { + case info.IsDir(): + if checkDev { + if dev, ok := deviceOf(info); ok && dev != rootDev { + // -x: a different filesystem contributes nothing. + break + } + } + size, err = dirSize(ctx, path, rootDev, checkDev) + if err != nil { + return err + } + case info.Mode().IsRegular(): + size = info.Size() + } + + items = append(items, entrySize{path: path, size: size}) + total += size + } + + sort.SliceStable(items, func(i, j int) bool { + if items[i].size != items[j].size { + return items[i].size > items[j].size + } + return items[i].path < items[j].path + }) + + w := req.Stdout + for _, item := range items { + fmt.Fprintf(w, "%-8s %s\n", humanSize(item.size), item.path) + } + fmt.Fprintf(w, "%-8s 总计 %s\n", humanSize(total), root) + return nil +} + +// parseDiskUsageArgs accepts either a positional path or --path/--path=value. +// Exactly one path is required. +func parseDiskUsageArgs(args []string) ([]string, error) { + var paths []string + positionalOnly := false + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case positionalOnly: + paths = append(paths, arg) + case arg == "--": + positionalOnly = true + case arg == "--path": + if i+1 >= len(args) { + return nil, errors.New("--path 缺少参数,例如 --path /var") + } + paths = append(paths, args[i+1]) + i++ + case strings.HasPrefix(arg, "--path="): + value := strings.TrimPrefix(arg, "--path=") + if value == "" { + return nil, errors.New("--path 缺少参数,例如 --path /var") + } + paths = append(paths, value) + case strings.HasPrefix(arg, "-") && arg != "-": + return nil, fmt.Errorf("未知选项 %q:深度(-d 1)、人类可读(-h)与不跨文件系统(-x)已固定", arg) + default: + paths = append(paths, arg) + } + } + + if len(paths) == 0 { + return nil, errors.New("必须提供路径,例如 kopsc disk usage /var 或 kopsc disk usage --path /var") + } + if len(paths) > 1 { + return nil, fmt.Errorf("一次只支持一个路径,收到 %d 个", len(paths)) + } + return paths, nil +} + +// dirSize sums the sizes of regular files under dir without following symbolic +// links or crossing into another filesystem. +func dirSize(ctx context.Context, dir string, rootDev uint64, checkDev bool) (int64, error) { + var total int64 + + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + // Permission problems on one subtree must not abort the report. + return nil + } + if err := ctx.Err(); err != nil { + return err + } + if d.IsDir() { + if path != dir && checkDev { + if dev, ok := deviceOfPath(path); ok && dev != rootDev { + return fs.SkipDir + } + } + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + if info.Mode().IsRegular() { + total += info.Size() + } + return nil + }) + if err != nil { + return total, err + } + return total, nil +} + +// humanSize formats bytes the way `du -h` does: bytes below 1 KiB are printed +// bare, larger values use binary units with one decimal. +func humanSize(n int64) string { + if n < 1024 { + return strconv.FormatInt(n, 10) + } + units := []string{"K", "M", "G", "T", "P", "E"} + value := float64(n) + unit := -1 + for value >= 1024 && unit < len(units)-1 { + value /= 1024 + unit++ + } + return fmt.Sprintf("%.1f%s", value, units[unit]) +} diff --git a/internal/executor/diskusage_test.go b/internal/executor/diskusage_test.go new file mode 100644 index 0000000..41bf23d --- /dev/null +++ b/internal/executor/diskusage_test.go @@ -0,0 +1,198 @@ +package executor + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry" +) + +func registryVariantBuiltin(name string) registry.Variant { + return registry.Variant{Builtin: name, Match: registry.Match{OS: "linux"}} +} + +func writeSizedFile(t *testing.T, path string, size int) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, bytes.Repeat([]byte("x"), size), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +// fixture builds: +// +// root/big/big.bin 4096 +// root/direct.txt 2048 +// root/small/small.txt 1024 +// root/empty/ (no files) +func diskFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeSizedFile(t, filepath.Join(root, "big", "big.bin"), 4096) + writeSizedFile(t, filepath.Join(root, "direct.txt"), 2048) + writeSizedFile(t, filepath.Join(root, "small", "small.txt"), 1024) + if err := os.MkdirAll(filepath.Join(root, "empty"), 0o755); err != nil { + t.Fatal(err) + } + return root +} + +func runDisk(t *testing.T, args ...string) (string, error) { + t.Helper() + var out bytes.Buffer + err := runDiskUsage(context.Background(), BuiltinRequest{Args: args, Stdout: &out}) + return out.String(), err +} + +func TestDiskUsageOrdersBySizeDescending(t *testing.T) { + root := diskFixture(t) + + out, err := runDisk(t, root) + if err != nil { + t.Fatalf("runDiskUsage error: %v", err) + } + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 5 { + t.Fatalf("expected 4 entries + total, got %d:\n%s", len(lines), out) + } + + wantOrder := []struct { + size string + name string + }{ + {"4.0K", "big"}, + {"2.0K", "direct.txt"}, + {"1.0K", "small"}, + {"0", "empty"}, + } + for i, want := range wantOrder { + if !strings.HasPrefix(lines[i], want.size) || !strings.Contains(lines[i], want.name) { + t.Errorf("line %d = %q, want size %s and name %s", i, lines[i], want.size, want.name) + } + } + if !strings.HasSuffix(lines[4], "总计 "+root) { + t.Errorf("total line = %q, want suffix %q", lines[4], "总计 "+root) + } + if !strings.HasPrefix(lines[4], "7.0K") { + t.Errorf("total line = %q, want 7.0K", lines[4]) + } +} + +func TestDiskUsageAcceptsPathFlag(t *testing.T) { + root := diskFixture(t) + + for _, args := range [][]string{ + {"--path", root}, + {"--path=" + root}, + {"--path", root, "--"}, + } { + out, err := runDisk(t, args...) + if err != nil { + t.Fatalf("args %v: %v", args, err) + } + if !strings.Contains(out, "big") || !strings.Contains(out, "总计") { + t.Errorf("args %v produced unexpected output:\n%s", args, out) + } + } +} + +func TestDiskUsageRequiresPath(t *testing.T) { + _, err := runDisk(t) + if err == nil || !strings.Contains(err.Error(), "必须提供路径") { + t.Fatalf("err = %v, want missing-path error", err) + } +} + +func TestDiskUsageRejectsBadArgs(t *testing.T) { + tests := []struct { + args []string + want string + }{ + {[]string{"--max-depth=2", "/var"}, "未知选项"}, + {[]string{"-h", "/var"}, "未知选项"}, + {[]string{"/var", "/tmp"}, "一次只支持一个路径"}, + {[]string{"--path"}, "--path 缺少参数"}, + {[]string{"--path="}, "--path 缺少参数"}, + } + for _, tc := range tests { + _, err := runDisk(t, tc.args...) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.want) + } + } +} + +func TestDiskUsageRejectsFile(t *testing.T) { + file := filepath.Join(t.TempDir(), "f.txt") + writeSizedFile(t, file, 10) + + _, err := runDisk(t, file) + if err == nil || !strings.Contains(err.Error(), "不是目录") { + t.Fatalf("err = %v, want not-a-directory error", err) + } +} + +func TestHumanSize(t *testing.T) { + cases := map[int64]string{ + 0: "0", + 512: "512", + 1023: "1023", + 1024: "1.0K", + 1536: "1.5K", + 1024 * 1024: "1.0M", + 1024 * 1024 * 1024: "1.0G", + 1 << 40: "1.0T", + } + for in, want := range cases { + if got := humanSize(in); got != want { + t.Errorf("humanSize(%d) = %q, want %q", in, got, want) + } + } +} + +func TestBuiltinIsRegistered(t *testing.T) { + if _, ok := LookupBuiltin("disk.usage"); !ok { + t.Fatal("disk.usage builtin not registered") + } + if _, ok := LookupBuiltin("missing"); ok { + t.Fatal("unexpected builtin hit") + } +} + +func TestPlanRunsBuiltin(t *testing.T) { + root := diskFixture(t) + plan := samplePlan() + plan.Result.Variant = registryVariantBuiltin("disk.usage") + plan.ExtraArgs = []string{root} + + var out bytes.Buffer + if err := plan.Run(context.Background(), Options{Stdout: &out, Stderr: &out}); err != nil { + t.Fatalf("Run() error: %v", err) + } + if !strings.Contains(out.String(), "总计") { + t.Errorf("builtin output missing total:\n%s", out.String()) + } + if !strings.Contains(plan.Display(), "(内置) disk.usage") { + t.Errorf("Display() = %q", plan.Display()) + } +} + +func TestPlanBuiltinDryRunDoesNotExecute(t *testing.T) { + plan := samplePlan() + plan.Result.Variant = registryVariantBuiltin("disk.usage") + plan.ExtraArgs = []string{filepath.Join(t.TempDir(), "missing")} + + var out bytes.Buffer + if err := plan.Run(context.Background(), Options{DryRun: true, Stdout: &out}); err != nil { + t.Fatalf("dry-run should not execute: %v", err) + } + if !strings.Contains(out.String(), "内置实现") { + t.Errorf("dry-run output lacks builtin note:\n%s", out.String()) + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index c3e6b96..ceb8b3a 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -42,6 +42,10 @@ func (p Plan) Argv() []string { // Display renders the command line the plan would run. func (p Plan) Display() string { + if p.Result.Variant.Builtin != "" { + parts := append([]string{"(内置)", p.Result.Variant.Builtin}, p.ExtraArgs...) + return strings.Join(parts, " ") + } argv := p.Argv() if p.Result.Variant.Shell { flag := "-c" @@ -61,9 +65,12 @@ func (p Plan) Explain(w io.Writer) { variant := p.Result.Variant fmt.Fprintf(w, "将执行: %s\n", p.Display()) fmt.Fprintf(w, "平台: %s\n", resolve.DescribeInfo(p.Result.Info)) - if variant.Shell { + switch { + case variant.Builtin != "": + fmt.Fprintf(w, "方式: 内置实现(%s,不启动外部进程)\n", variant.Builtin) + case variant.Shell: fmt.Fprintf(w, "方式: shell (%s)\n", shellName()) - } else { + default: fmt.Fprintf(w, "方式: 直接执行\n") } note := variant.Note @@ -94,6 +101,14 @@ func (p Plan) Run(ctx context.Context, opts Options) error { return nil } + if variant := p.Result.Variant; variant.Builtin != "" { + fn, ok := LookupBuiltin(variant.Builtin) + if !ok { + return fmt.Errorf("未注册的内置动作 %q", variant.Builtin) + } + return fn(ctx, BuiltinRequest{Args: p.ExtraArgs, Stdout: opts.Stdout, Stderr: opts.Stderr}) + } + name, args := p.command() cmd := exec.CommandContext(ctx, name, args...) cmd.Stdin = opts.Stdin diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index 9e50e6b..2b7bf6e 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -44,6 +44,32 @@ func Builtin() *Registry { }, }, }, + { + ID: "disk.usage", + Aliases: []string{"disk.du"}, + Group: "disk", + Summary: "列出目录下各直接子项的占用空间,按大小降序", + Action: "等价于 du -x --max-depth=1 -h <路径> | sort -hr:统计每个直接子项(文件或目录递归)的占用," + + "不跨越文件系统边界,末尾输出该路径的合计。路径必填,可作位置参数或用 --path 指定;" + + "由于 Windows 没有等价的 du/sort,这里使用 kopsc 内置实现以保证三平台输出一致", + Variants: []Variant{ + { + Match: Match{OS: "linux"}, + Builtin: "disk.usage", + Note: "Linux 内置实现(等价 du -x -d 1 -h | sort -hr)", + }, + { + Match: Match{OS: "darwin"}, + Builtin: "disk.usage", + Note: "macOS 内置实现(BSD du 不支持 --max-depth)", + }, + { + Match: Match{OS: "windows"}, + Builtin: "disk.usage", + Note: "Windows 内置实现(系统没有 du/sort)", + }, + }, + }, { ID: "net.ports", Group: "net", diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 567ffb9..99b3538 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -22,7 +22,11 @@ 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` + // Builtin names an in-process implementation registered with the executor. + // It is mutually exclusive with Argv and lets kopsc provide commands that + // have no equivalent system utility (for example on Windows). + Builtin string + Note string // human-readable rationale shown by `kopsc explain` } // Command is a platform-neutral intent, e.g. "docker.clearsafe". @@ -73,7 +77,13 @@ func (r *Registry) Validate() error { } for j, variant := range cmd.Variants { - if len(variant.Argv) == 0 || strings.TrimSpace(variant.Argv[0]) == "" { + if len(variant.Argv) == 0 && variant.Builtin == "" { + return fmt.Errorf("command %q variant #%d: empty argv and no builtin", cmd.ID, j) + } + if len(variant.Argv) > 0 && variant.Builtin != "" { + return fmt.Errorf("command %q variant #%d: argv and builtin are mutually exclusive", cmd.ID, j) + } + 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 { diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index fd68766..9ea2bfa 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -15,7 +15,7 @@ func TestBuiltinIsValid(t *testing.T) { func TestBuiltinIDsStable(t *testing.T) { reg := Builtin() - want := []string{"docker.clearsafe", "net.ports", "pkg.refreshindex"} + want := []string{"docker.clearsafe", "disk.usage", "net.ports", "pkg.refreshindex"} if got := reg.IDs(); !reflect.DeepEqual(got, want) { t.Fatalf("IDs() = %v, want %v", got, want) } @@ -132,6 +132,45 @@ func TestValidateErrors(t *testing.T) { } } +func TestValidateBuiltinVariants(t *testing.T) { + builtinOnly := plainCommand("a") + builtinOnly.Variants = []Variant{{Match: Match{OS: "linux"}, Builtin: "demo.action"}} + + if err := (&Registry{Commands: []Command{builtinOnly}}).Validate(); err != nil { + t.Fatalf("builtin-only variant should be valid: %v", err) + } + + both := plainCommand("a") + both.Variants = []Variant{{Match: Match{OS: "linux"}, Argv: []string{"x"}, Builtin: "demo.action"}} + err := (&Registry{Commands: []Command{both}}).Validate() + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err = %v, want mutual-exclusion error", err) + } +} + +func TestDiskUsageIsBuiltinEverywhere(t *testing.T) { + reg := Builtin() + cmd, ok := reg.Lookup("disk.usage") + if !ok { + t.Fatal("disk.usage not found") + } + seen := map[string]bool{} + for _, variant := range cmd.Variants { + if variant.Builtin != "disk.usage" { + t.Errorf("variant for %s should use the disk.usage builtin, got %q", variant.Match.OS, variant.Builtin) + } + if len(variant.Argv) != 0 { + t.Errorf("builtin variant must not carry argv: %v", variant.Argv) + } + seen[variant.Match.OS] = true + } + for _, osName := range []string{"linux", "darwin", "windows"} { + if !seen[osName] { + t.Errorf("disk.usage lacks a %s variant", osName) + } + } +} + 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"}