From 038926e2ddefc44c91cb80cc6f8e56fca45d948b Mon Sep 17 00:00:00 2001 From: Odboy Date: Mon, 2 Nov 2026 22:15:30 +0800 Subject: [PATCH] =?UTF-8?q?feature:=20=E7=94=A8=E6=88=B7=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E3=80=81=E8=A7=A3=E9=99=A4=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + README.user.md | 131 ++++++++++++++++++++++++ internal/cli/cli.go | 1 + internal/executor/builtin.go | 2 + internal/executor/gitpush_test.go | 82 ++++++++++++++- internal/executor/gitrewrite.go | 110 +++++++++++++++++--- internal/executor/usergrant.go | 152 ++++++++++++++++++++++++++++ internal/executor/usergrant_test.go | 61 +++++++++++ internal/registry/builtin.go | 58 +++++++++++ internal/registry/registry_test.go | 1 + 10 files changed, 581 insertions(+), 21 deletions(-) create mode 100644 README.user.md create mode 100644 internal/executor/usergrant.go create mode 100644 internal/executor/usergrant_test.go diff --git a/README.md b/README.md index fa3d8b6..87b88ef 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | **java** | Java 应用:按 JDK 版本套用调优参数并运行 jar | [README.java.md](README.java.md) | | **git** | Git 历史:按日期范围调整提交时间并强推 | [README.git.md](README.git.md) | | **pkg** | 系统软件包:刷新包管理器索引 | [README.pkg.md](README.pkg.md) | +| **user** | 用户管理:授予或移除用户的 root/sudo 权限 | [README.user.md](README.user.md) | 辅助命令(不涉及具体资源,直接用即可): @@ -177,6 +178,8 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | 跑 Java 应用 | `kopsc java run app.jar`、`kopsc java run --jdk 17 --print-flags app.jar` | | 调整提交时间 | `kopsc git retime 2026-10-01 2026-10-15`(加 `--yes` 才执行) | | 刷新软件源 | `kopsc pkg refreshindex` | +| 授予用户 root 权限 | `kopsc user grant alice --yes` | +| 移除用户 root 权限 | `kopsc user revoke alice --yes` | ## 平台识别 @@ -239,6 +242,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、 | pkg | [`README.pkg.md`](README.pkg.md) | | java | [`README.java.md`](README.java.md) | | git | [`README.git.md`](README.git.md) | +| user | [`README.user.md`](README.user.md) | 新分组需要新建 `README.<分组>.md`,并在本文件「命令一览」的表格里加一行链接。 每条命令至少要写清:**作用**、**实际执行什么**、**示例**;有独有选项时补选项表。 diff --git a/README.user.md b/README.user.md new file mode 100644 index 0000000..2ac24f5 --- /dev/null +++ b/README.user.md @@ -0,0 +1,131 @@ +# 用户管理 + +跨平台的用户权限管理命令,简化 Linux 和 macOS 上繁琐的 root/sudo 权限操作。 + +## 命令一览 + +| 命令 | 用途 | 实际执行 | +| --- | --- | --- | +| `kopsc user grant <用户名>` | 授予用户 root/sudo 权限 | Linux: `usermod -aG wheel/sudo <用户名>`
macOS: `dseditgroup -o edit -t user -a <用户名> admin`
Windows: `net localgroup Administrators <用户名> /add` | +| `kopsc user revoke <用户名>` | 移除用户的 root/sudo 权限 | Linux: `gpasswd -d <用户名> wheel/sudo`
macOS: `dseditgroup -o edit -t user -d <用户名> admin`
Windows: `net localgroup Administrators <用户名> /delete` | + +## 用法 + +### 授予用户 root/sudo 权限 + +```bash +# 预览将执行的操作 +kopsc user grant alice + +# 确认执行(需要当前用户已有 sudo/管理员权限) +kopsc user grant alice --yes + +# 简写 +kopsc user grant alice -y +``` + +**执行后效果**: +- **Linux**:用户 `alice` 被添加到 `wheel` 组(CentOS/RHEL)或 `sudo` 组(Debian/Ubuntu),可以使用 `sudo` 执行管理员命令 +- **macOS**:用户 `alice` 被添加到 `admin` 组,获得管理员权限 +- **Windows**:用户 `alice` 被添加到 `Administrators` 组,获得管理员权限 + +### 移除用户的 root/sudo 权限 + +```bash +# 预览将执行的操作 +kopsc user revoke alice + +# 确认执行 +kopsc user revoke alice --yes +``` + +**执行后效果**: +- **Linux**:用户 `alice` 从 `wheel` 或 `sudo` 组移除,失去 sudo 权限 +- **macOS**:用户 `alice` 从 `admin` 组移除,失去管理员权限 +- **Windows**:用户 `alice` 从 `Administrators` 组移除,失去管理员权限 + +## 注意事项 + +1. **需要管理员权限**:执行这些命令的当前用户必须已有 sudo/管理员权限 +2. **重新登录生效**:权限变更需要用户重新登录后才能生效 +3. **破坏性命令**:这两个命令都需要 `--yes` 确认,避免误操作 +4. **自动检测发行版**:Linux 上会自动检测应该使用 `wheel` 组还是 `sudo` 组 + +## 平台差异 + +### Linux + +不同发行版使用不同的管理员组: +- **CentOS/RHEL/Rocky/AlmaLinux**:使用 `wheel` 组 +- **Debian/Ubuntu/Mint**:使用 `sudo` 组 + +kopsc 会自动检测系统中存在的组,优先使用 `wheel`,其次是 `sudo`。 + +### macOS + +macOS 使用 `admin` 组管理管理员权限,通过 `dseditgroup` 命令操作。 + +### Windows + +Windows 使用 `Administrators` 本地组,通过 `net localgroup` 命令操作。 + +## 示例场景 + +### 场景 1:新入职的运维工程师需要 sudo 权限 + +```bash +# 创建用户(系统命令) +sudo useradd -m -s /bin/bash ops01 + +# 授予 sudo 权限(kopsc 命令) +kopsc user grant ops01 --yes + +# 通知用户重新登录 +echo "请重新登录以启用 sudo 权限" +``` + +### 场景 2:离职员工需要移除管理员权限 + +```bash +# 移除管理员权限 +kopsc user revoke former_employee --yes + +# 可选:禁用账户(系统命令) +sudo usermod -L former_employee +``` + +### 场景 3:批量管理用户权限 + +```bash +# 批量授权 +for user in alice bob charlie; do + kopsc user grant $user --yes +done + +# 批量撤销 +for user in alice bob charlie; do + kopsc user revoke $user --yes +done +``` + +## 故障排查 + +### 问题:提示"未找到 wheel 或 sudo 组" + +**原因**:系统使用了非标准的管理员组名称 + +**解决**: +1. 查看系统的管理员组:`cat /etc/group | grep -E '^(wheel|sudo|admin)'` +2. 手动执行命令:`sudo usermod -aG <组名> <用户名>` + +### 问题:执行后用户仍无法使用 sudo + +**原因**:权限变更需要重新登录才能生效 + +**解决**:让用户退出并重新登录,或执行 `su - <用户名>` 切换到该用户 + +### 问题:提示权限不足 + +**原因**:当前用户没有 sudo/管理员权限 + +**解决**:使用已有管理员权限的账户执行,或临时使用 `sudo` 前缀 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 447e724..d080bd7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -223,6 +223,7 @@ var groupDescriptions = map[string]string{ "proc": "本机进程:在后台运行命令并写日志,关掉终端也不退出", "java": "Java 应用:按 JDK 版本自动套用调优参数并前台运行 jar", "git": "Git 历史:把指定日期范围的提交时间改到 21:00-23:00 并强推", + "user": "用户管理:授予或移除用户的 root/sudo 权限", } // newGroup builds an intermediate command such as `kopsc disk`. diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index 512c0f8..508fc15 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -54,6 +54,8 @@ var builtins = map[string]Builtin{ "net.ports": runNetPorts, "proc.nohup": runNohup, "proc.wait": runProcWait, + "user.grant": runUserGrant, + "user.revoke": runUserRevoke, } // runDiagnose bridges the registry builtin to the diagnose package. diff --git a/internal/executor/gitpush_test.go b/internal/executor/gitpush_test.go index 70adf13..4fc7e03 100644 --- a/internal/executor/gitpush_test.go +++ b/internal/executor/gitpush_test.go @@ -25,10 +25,15 @@ func TestForcePushUsesLease(t *testing.T) { } } -func TestForcePushExplainsRejectedLease(t *testing.T) { +// lfsAdvisory is the git-lfs note that used to be mistaken for the cause. +const lfsAdvisory = `Locking support detected on remote "origin". Consider enabling it with: + $ git config lfs.https://gitea.example.com/a/b.git/info/lfs.locksverify true +` + +func TestForcePushExplainsLeaseRejection(t *testing.T) { runner := &fakeGitRunner{responses: map[string]gitResponse{ "push --force-with-lease origin main": { - errOut: " ! [rejected] main -> main (stale info)", + errOut: lfsAdvisory + " ! [rejected] main -> main (stale info)\n", err: errors.New("exit status 1"), }, }} @@ -36,11 +41,64 @@ func TestForcePushExplainsRejectedLease(t *testing.T) { if err == nil { t.Fatal("expected an error") } - for _, want := range []string{"强推被拒绝", "git fetch", "回滚"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("error missing %q: %v", want, err) + msg := err.Error() + for _, want := range []string{"租约校验失败", "--force-with-lease=main:", "回滚"} { + if !strings.Contains(msg, want) { + t.Errorf("message missing %q:\n%s", want, msg) } } + // The git-lfs advice is noise here and must not lead the message. + if strings.Contains(msg, "Locking support detected") { + t.Errorf("LFS advisory leaked into the lease message:\n%s", msg) + } +} + +func TestForcePushExplainsLFSMissing(t *testing.T) { + stderr := lfsAdvisory + + "Git LFS upload failed: 0% (0/1)\n" + + " (missing) doc/ffmpeg.zip (6080e34165fcb0261f979bb1cef26c5fce616e2a416384b50bcce956aa265018)\n" + + "hint: Your push was rejected due to missing or corrupt local objects.\n" + + "hint: You can disable this check with: `git config lfs.allowincompletepush true`\n" + runner := &fakeGitRunner{responses: map[string]gitResponse{ + "push --force-with-lease origin main": {errOut: stderr, err: errors.New("exit status 1")}, + }} + err := forcePush(context.Background(), runner, "origin", "main") + if err == nil { + t.Fatal("expected an error") + } + msg := err.Error() + for _, want := range []string{ + "LFS 完整性检查", + "doc/ffmpeg.zip", + "lfs.allowincompletepush=true", + } { + if !strings.Contains(msg, want) { + t.Errorf("message missing %q:\n%s", want, msg) + } + } + // The lease wording would mislead: LFS, not the remote, blocked this push. + if strings.Contains(msg, "租约") { + t.Errorf("LFS failure was reported as a lease problem:\n%s", msg) + } + if strings.Contains(msg, "Locking support detected") { + t.Errorf("LFS advisory leaked into the message:\n%s", msg) + } +} + +func TestForcePushExplainsProtectedBranch(t *testing.T) { + runner := &fakeGitRunner{responses: map[string]gitResponse{ + "push --force-with-lease origin main": { + errOut: "remote: error: GH006: protected branch update failed\nremote: pre-receive hook declined\n", + err: errors.New("exit status 1"), + }, + }} + err := forcePush(context.Background(), runner, "origin", "main") + if err == nil || !strings.Contains(err.Error(), "分支保护") { + t.Fatalf("err = %v, want a branch-protection message", err) + } + if strings.Contains(err.Error(), "租约") { + t.Errorf("protected branch was reported as a lease problem: %v", err) + } } func TestIsRejectedPush(t *testing.T) { @@ -57,6 +115,10 @@ func TestIsRejectedPush(t *testing.T) { if isRejectedPush("fatal: unable to access remote") { t.Error("network errors are not lease rejections") } + // An LFS failure is not a remote-side rejection. + if isRejectedPush("Git LFS upload failed:\n (missing) a.zip (abc)") { + t.Error("LFS failures must not be classified as lease rejections") + } } func TestForcePushReportsNetworkError(t *testing.T) { @@ -70,3 +132,13 @@ func TestForcePushReportsNetworkError(t *testing.T) { t.Fatalf("err = %v, want a git push failure", err) } } + +func TestFirstMissingObject(t *testing.T) { + stderr := "Git LFS upload failed:\n (missing) doc/a.zip (abc123)\n (missing) upx.exe (def456)" + if got := firstMissingObject(stderr); got != "doc/a.zip" { + t.Errorf("firstMissingObject = %q", got) + } + if got := firstMissingObject("no entries"); got != "未知对象" { + t.Errorf("firstMissingObject = %q, want a placeholder", got) + } +} diff --git a/internal/executor/gitrewrite.go b/internal/executor/gitrewrite.go index ed36210..f100ace 100644 --- a/internal/executor/gitrewrite.go +++ b/internal/executor/gitrewrite.go @@ -240,27 +240,105 @@ func backupHint(branch, ref string) string { // // --force-with-lease is deliberate: it refuses to overwrite commits the local // repository has not seen, so a colleague's push is never silently discarded. +// +// git writes a lot to stderr on a failed push, and only some of it explains the +// failure: git-lfs prints advisory notes ("Locking support detected…") that used +// to be shown as if they were the cause. The error below classifies what +// actually went wrong and tells the user what to run next. func forcePush(ctx context.Context, runner gitRunner, remote, branch string) error { - out, stderr, err := runner.Run(ctx, "push", "--force-with-lease", remote, branch) - if err != nil { - if isRejectedPush(stderr) { - return fmt.Errorf("远程分支 %s/%s 已有本地未知的新提交,强推被拒绝;"+ - "请先 git fetch 并确认后再试(本地历史已改写,可用备份引用回滚): %s", - remote, branch, strings.TrimSpace(stderr)) - } - return gitError("git push", stderr, err) + _, stderr, err := runner.Run(ctx, "push", "--force-with-lease", remote, branch) + if err == nil { + return nil } - _ = out - return nil + return classifyPushError(remote, branch, stderr, err) } -// isRejectedPush reports whether git refused the push because the remote moved -// (force-with-lease stale info, or a protected branch). +// classifyPushError turns git's stderr into one actionable message. +func classifyPushError(remote, branch, stderr string, err error) error { + detail := pushDetail(stderr) + + switch { + case isLFSIncomplete(stderr): + return fmt.Errorf("强推被 LFS 完整性检查拦下:本地缺少远程要接收的 LFS 对象(%s)。\n"+ + "如果当前提交用到的对象都已存在,可以跳过检查:\n"+ + " git -c lfs.allowincompletepush=true push --force-with-lease %s %s", + firstMissingObject(stderr), remote, branch) + + case isLeaseRejected(stderr): + return fmt.Errorf("强推被拒绝:远程分支 %s/%s 有本地未知的新提交(租约校验失败)。\n"+ + "确认远端确实该被覆盖后,显式指定远端当前 SHA 再推:\n"+ + " git push --force-with-lease=%s:<远端SHA> %s %s\n"+ + "(本地历史已改写,如需撤销可用备份引用回滚)\n%s", + remote, branch, branch, remote, branch, detail) + + case isProtected(stderr): + return fmt.Errorf("强推被拒绝:%s/%s 受分支保护,服务器不允许改写历史。\n"+ + "请先在仓库设置里临时放开保护,或改推到另一个分支后再合并。\n%s", + remote, branch, detail) + + default: + return gitError("git push", stderr, err) + } +} + +// pushDetail keeps git's own output for the message, dropping the advisory +// lines that only repeat what kopsc already says. +func pushDetail(stderr string) string { + var kept []string + for _, line := range strings.Split(stderr, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || isLFSAdvisory(trimmed) { + continue + } + kept = append(kept, trimmed) + } + return strings.Join(kept, "\n") +} + +// isLFSAdvisory reports whether a line is git-lfs advice rather than a failure. +func isLFSAdvisory(line string) bool { + return strings.HasPrefix(line, "Locking support detected") || + strings.HasPrefix(line, "$ git config lfs.") || + strings.HasPrefix(line, "hint: You can disable this check") +} + +// isLeaseRejected reports whether git refused because the remote moved. +func isLeaseRejected(stderr string) bool { + return strings.Contains(stderr, "stale info") || + strings.Contains(stderr, "[rejected]") || + (strings.Contains(stderr, "rejected") && !isLFSIncomplete(stderr)) +} + +// isProtected reports a branch-protection refusal. +func isProtected(stderr string) bool { + return strings.Contains(stderr, "protected branch") || + strings.Contains(stderr, "pre-receive hook declined") || + strings.Contains(stderr, "hook declined") +} + +// isLFSIncomplete reports that the push was stopped by git-lfs upload checks. +func isLFSIncomplete(stderr string) bool { + return strings.Contains(stderr, "LFS upload failed") || + strings.Contains(stderr, "LFS upload missing objects") || + strings.Contains(stderr, "(missing)") +} + +// isRejectedPush reports whether git refused the push for a remote-side reason, +// as opposed to a local failure such as LFS objects. func isRejectedPush(stderr string) bool { - for _, marker := range []string{"stale info", "rejected", "protected branch", "hook declined"} { - if strings.Contains(stderr, marker) { - return true + return isLeaseRejected(stderr) || isProtected(stderr) +} + +// firstMissingObject extracts the first "(missing) ()" entry. +func firstMissingObject(stderr string) string { + for _, line := range strings.Split(stderr, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "(missing)") { + fields := strings.Fields(strings.TrimPrefix(line, "(missing)")) + if len(fields) > 0 { + return fields[0] + } } } - return false + return "未知对象" } diff --git a/internal/executor/usergrant.go b/internal/executor/usergrant.go new file mode 100644 index 0000000..49567e6 --- /dev/null +++ b/internal/executor/usergrant.go @@ -0,0 +1,152 @@ +package executor + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strings" +) + +// runUserGrant implements `kopsc user grant `: add a user to the +// sudo/admin group to grant root privileges. +// +// Linux: adds to 'wheel' (CentOS/RHEL) or 'sudo' (Debian/Ubuntu) +// macOS: adds to 'admin' group +// Windows: adds to 'Administrators' group +// +// This is a destructive operation requiring sudo/admin privileges. +func runUserGrant(ctx context.Context, req BuiltinRequest) error { + if len(req.Args) == 0 { + return fmt.Errorf("缺少用户名;用法: kopsc user grant <用户名>") + } + username := req.Args[0] + + if err := checkUserExists(username); err != nil { + return err + } + + switch runtime.GOOS { + case "linux": + return grantLinux(ctx, username, req) + case "darwin": + return grantDarwin(ctx, username, req) + case "windows": + return grantWindows(ctx, username, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +// runUserRevoke implements `kopsc user revoke `: remove a user from +// the sudo/admin group to revoke root privileges. +func runUserRevoke(ctx context.Context, req BuiltinRequest) error { + if len(req.Args) == 0 { + return fmt.Errorf("缺少用户名;用法: kopsc user revoke <用户名>") + } + username := req.Args[0] + + if err := checkUserExists(username); err != nil { + return err + } + + switch runtime.GOOS { + case "linux": + return revokeLinux(ctx, username, req) + case "darwin": + return revokeDarwin(ctx, username, req) + case "windows": + return revokeWindows(ctx, username, req) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +// grantLinux adds user to wheel (CentOS/RHEL) or sudo (Debian/Ubuntu) group. +func grantLinux(ctx context.Context, username string, req BuiltinRequest) error { + // Detect distro by checking which group exists + if groupExists("wheel") { + return runCommand(ctx, "usermod", []string{"-aG", "wheel", username}, req) + } + if groupExists("sudo") { + return runCommand(ctx, "usermod", []string{"-aG", "sudo", username}, req) + } + return fmt.Errorf("未找到 wheel 或 sudo 组,请确认系统配置") +} + +// revokeLinux removes user from wheel or sudo group. +func revokeLinux(ctx context.Context, username string, req BuiltinRequest) error { + if groupExists("wheel") { + return runCommand(ctx, "gpasswd", []string{"-d", username, "wheel"}, req) + } + if groupExists("sudo") { + return runCommand(ctx, "gpasswd", []string{"-d", username, "sudo"}, req) + } + return fmt.Errorf("未找到 wheel 或 sudo 组,请确认系统配置") +} + +// grantDarwin adds user to admin group on macOS. +func grantDarwin(ctx context.Context, username string, req BuiltinRequest) error { + return runCommand(ctx, "dseditgroup", []string{"-o", "edit", "-t", "user", "-a", username, "admin"}, req) +} + +// revokeDarwin removes user from admin group on macOS. +func revokeDarwin(ctx context.Context, username string, req BuiltinRequest) error { + return runCommand(ctx, "dseditgroup", []string{"-o", "edit", "-t", "user", "-d", username, "admin"}, req) +} + +// grantWindows adds user to Administrators group. +func grantWindows(ctx context.Context, username string, req BuiltinRequest) error { + return runCommand(ctx, "net", []string{"localgroup", "Administrators", username, "/add"}, req) +} + +// revokeWindows removes user from Administrators group. +func revokeWindows(ctx context.Context, username string, req BuiltinRequest) error { + return runCommand(ctx, "net", []string{"localgroup", "Administrators", username, "/delete"}, req) +} + +// runCommand executes a command and streams output to the request's stdout/stderr. +func runCommand(ctx context.Context, name string, args []string, req BuiltinRequest) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + cmd.Stdin = req.Stdin + + if err := cmd.Run(); err != nil { + return fmt.Errorf("执行失败: %w", err) + } + return nil +} + +// groupExists checks if a Unix group exists on the system. +func groupExists(name string) bool { + out, err := exec.Command("getent", "group", name).Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) != "" +} + +// checkUserExists verifies that the given username exists on the system. +// Returns a user-friendly error if the user does not exist. +func checkUserExists(username string) error { + var exists bool + + switch runtime.GOOS { + case "linux", "darwin": + // id command works on both Linux and macOS + err := exec.Command("id", username).Run() + exists = (err == nil) + case "windows": + // net user command on Windows + err := exec.Command("net", "user", username).Run() + exists = (err == nil) + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } + + if !exists { + return fmt.Errorf("用户 %q 不存在", username) + } + return nil +} diff --git a/internal/executor/usergrant_test.go b/internal/executor/usergrant_test.go new file mode 100644 index 0000000..f43598a --- /dev/null +++ b/internal/executor/usergrant_test.go @@ -0,0 +1,61 @@ +package executor + +import ( + "bytes" + "context" + "runtime" + "strings" + "testing" +) + +func TestUserGrantMissingArgs(t *testing.T) { + req := BuiltinRequest{ + Args: []string{}, + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } + err := runUserGrant(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), "缺少用户名") { + t.Errorf("expected missing username error, got %v", err) + } +} + +func TestUserRevokeMissingArgs(t *testing.T) { + req := BuiltinRequest{ + Args: []string{}, + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } + err := runUserRevoke(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), "缺少用户名") { + t.Errorf("expected missing username error, got %v", err) + } +} + +func TestUserGrantUnsupportedOS(t *testing.T) { + // This test only runs on unsupported platforms (none currently) + // We test the error path by mocking runtime.GOOS which is not possible + // in Go, so we just verify the function doesn't panic with valid input + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "windows" { + req := BuiltinRequest{ + Args: []string{"testuser"}, + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } + err := runUserGrant(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), "不支持的操作系统") { + t.Errorf("expected unsupported OS error, got %v", err) + } + } +} + +func TestGroupExists(t *testing.T) { + // Test with a group that should exist on most Unix systems + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + // 'staff' or 'wheel' should exist on macOS, 'root' or 'wheel' on Linux + exists := groupExists("root") || groupExists("wheel") || groupExists("staff") + if !exists { + t.Log("warning: none of root/wheel/staff groups found, this is unusual but not fatal") + } + } +} diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index dd182ed..2f64995 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -551,6 +551,64 @@ func Builtin() *Registry { }, }, }, + { + ID: "user.grant", + Group: "user", + Summary: "授予用户 root/sudo 权限", + Action: "将指定用户添加到系统的管理员组,使其获得 root/sudo 权限。" + + "Linux: 添加到 wheel(CentOS/RHEL)或 sudo(Debian/Ubuntu)组;" + + "macOS: 添加到 admin 组;Windows: 添加到 Administrators 组。" + + "需要当前用户已有 sudo/管理员权限;执行后用户需重新登录才能生效", + Usage: "<用户名>", + MinArgs: 1, + RequiresYes: true, + Variants: []Variant{ + { + Match: Match{OS: "linux"}, + Builtin: "user.grant", + Note: "Linux 内置实现(usermod -aG wheel/sudo)", + }, + { + Match: Match{OS: "darwin"}, + Builtin: "user.grant", + Note: "macOS 内置实现(dseditgroup -a admin)", + }, + { + Match: Match{OS: "windows"}, + Builtin: "user.grant", + Note: "Windows 内置实现(net localgroup Administrators /add)", + }, + }, + }, + { + ID: "user.revoke", + Group: "user", + Summary: "移除用户的 root/sudo 权限", + Action: "将指定用户从系统的管理员组移除,取消其 root/sudo 权限。" + + "Linux: 从 wheel(CentOS/RHEL)或 sudo(Debian/Ubuntu)组移除;" + + "macOS: 从 admin 组移除;Windows: 从 Administrators 组移除。" + + "需要当前用户已有 sudo/管理员权限;执行后用户需重新登录才能生效", + Usage: "<用户名>", + MinArgs: 1, + RequiresYes: true, + Variants: []Variant{ + { + Match: Match{OS: "linux"}, + Builtin: "user.revoke", + Note: "Linux 内置实现(gpasswd -d wheel/sudo)", + }, + { + Match: Match{OS: "darwin"}, + Builtin: "user.revoke", + Note: "macOS 内置实现(dseditgroup -d admin)", + }, + { + Match: Match{OS: "windows"}, + Builtin: "user.revoke", + Note: "Windows 内置实现(net localgroup Administrators /delete)", + }, + }, + }, }} } diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index a4ecaaf..77ba603 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -26,6 +26,7 @@ func TestBuiltinIDsStable(t *testing.T) { "docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs", "diagnose", "disk.usage", "net.pids", "net.ports", "proc.nohup", "proc.wait", "java.run", "git.retime", "pkg.refreshindex", + "user.grant", "user.revoke", } if got := reg.IDs(); !reflect.DeepEqual(got, want) { t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)