Compare commits

5 Commits

Author SHA1 Message Date
f1ebc52cca fix: allow reset-apikey command without user.manage permission
The reset-apikey command has its own auth mechanism via --acc-mgr-token,
so it should not be gated by permission introspection. This matches the
behavior of "user create" which is also Permitted: true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:32:30 +01:00
h z
de0ea39b2a Merge pull request 'dev-2026-03-29' (#3) from dev-2026-03-29 into main
Reviewed-on: #3
2026-04-16 21:21:32 +00:00
6dae490257 refactor: rename pass_mgr to secret-mgr
The secret manager binary was renamed from pass_mgr to secret-mgr.
Update all references in CLI code, mode detection, and help text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
53b5b88fc2 feat: user reset-apikey supports acc-mgr-token auth
Allows reset-apikey to use --acc-mgr-token or auto-resolve from
secret-mgr in padded-cell mode, enabling API key provisioning
without an existing user Bearer token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
6252039fc5 feat: add user reset-apikey command
Adds `hf user reset-apikey <username>` to regenerate a user API key.
Requires user.manage permission. Returns the new key (shown once only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:15:58 +00:00
8 changed files with 83 additions and 244 deletions

View File

@@ -224,12 +224,6 @@ func handleGroup(group help.Group, args []string) {
case "monitor": case "monitor":
handleMonitorCommand(sub.Name, remaining) handleMonitorCommand(sub.Name, remaining)
return return
case "schedule-type":
handleScheduleTypeCommand(sub.Name, remaining)
return
case "assign-schedule-type":
handleAssignScheduleType(remaining)
return
} }
if len(args) > 0 && args[0] == "update-discord-id" { if len(args) > 0 && args[0] == "update-discord-id" {
@@ -347,6 +341,11 @@ func handleUserCommand(subCmd string, args []string) {
output.Error("usage: hf user delete <username>") output.Error("usage: hf user delete <username>")
} }
commands.RunUserDelete(filtered[0], tokenFlag) commands.RunUserDelete(filtered[0], tokenFlag)
case "reset-apikey":
if len(filtered) < 1 {
output.Error("usage: hf user reset-apikey <username>")
}
commands.RunUserResetAPIKey(filtered[0], tokenFlag, accMgrTokenFlag)
default: default:
output.Errorf("hf user %s is not implemented yet", subCmd) output.Errorf("hf user %s is not implemented yet", subCmd)
} }
@@ -1139,47 +1138,3 @@ func handleMonitorAPIKeyCommand(args []string, tokenFlag string) {
output.Errorf("unknown monitor api-key subcommand: %s", subCmd) output.Errorf("unknown monitor api-key subcommand: %s", subCmd)
} }
} }
func handleScheduleTypeCommand(subCmd string, args []string) {
tokenFlag := ""
var filtered []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--token":
if i+1 < len(args) {
i++
tokenFlag = args[i]
}
default:
filtered = append(filtered, args[i])
}
}
switch subCmd {
case "list":
commands.RunScheduleTypeList(tokenFlag)
case "create":
commands.RunScheduleTypeCreate(filtered, tokenFlag)
case "delete":
commands.RunScheduleTypeDelete(filtered, tokenFlag)
default:
output.Errorf("hf schedule-type %s is not implemented yet", subCmd)
}
}
func handleAssignScheduleType(args []string) {
tokenFlag := ""
var filtered []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--token":
if i+1 < len(args) {
i++
tokenFlag = args[i]
}
default:
filtered = append(filtered, args[i])
}
}
commands.RunAssignScheduleType(filtered, tokenFlag)
}

View File

@@ -20,7 +20,7 @@ func RunConfigURL(url string) {
fmt.Printf("base-url set to %s\n", url) fmt.Printf("base-url set to %s\n", url)
} }
// RunConfigAccMgrToken stores the account-manager token via pass_mgr. // RunConfigAccMgrToken stores the account-manager token via secret-mgr.
func RunConfigAccMgrToken(token string) { func RunConfigAccMgrToken(token string) {
if token == "" { if token == "" {
output.Error("usage: hf config --acc-mgr-token <token>") output.Error("usage: hf config --acc-mgr-token <token>")

View File

@@ -1,165 +0,0 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
)
type scheduleTypeResponse struct {
ID int `json:"id"`
Name string `json:"name"`
WorkFrom int `json:"work_from"`
WorkTo int `json:"work_to"`
EntertainmentFrom int `json:"entertainment_from"`
EntertainmentTo int `json:"entertainment_to"`
}
// RunScheduleTypeList implements `hf schedule-type list`.
func RunScheduleTypeList(tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/schedule-types/")
if err != nil {
output.Errorf("failed to list schedule types: %v", err)
}
var types []scheduleTypeResponse
if err := json.Unmarshal(data, &types); err != nil {
output.Errorf("invalid response: %v", err)
}
if output.JSONMode {
output.PrintJSON(types)
return
}
if len(types) == 0 {
fmt.Println("No schedule types defined.")
return
}
fmt.Printf("%-4s %-20s %-12s %-12s\n", "ID", "Name", "Work", "Entertainment")
fmt.Printf("%-4s %-20s %-12s %-12s\n", "----", "--------------------", "------------", "------------")
for _, t := range types {
fmt.Printf("%-4d %-20s %02d:00-%02d:00 %02d:00-%02d:00\n",
t.ID, t.Name, t.WorkFrom, t.WorkTo, t.EntertainmentFrom, t.EntertainmentTo)
}
}
// RunScheduleTypeCreate implements `hf schedule-type create <name> --work <from>-<to> --entertainment <from>-<to>`.
func RunScheduleTypeCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf schedule-type create <name> --work <from>-<to> --entertainment <from>-<to>")
}
name := args[0]
workFrom, workTo, entFrom, entTo := -1, -1, -1, -1
for i := 1; i < len(args); i++ {
switch args[i] {
case "--work":
if i+1 < len(args) {
i++
fmt.Sscanf(args[i], "%d-%d", &workFrom, &workTo)
}
case "--entertainment":
if i+1 < len(args) {
i++
fmt.Sscanf(args[i], "%d-%d", &entFrom, &entTo)
}
}
}
if workFrom < 0 || workTo < 0 || entFrom < 0 || entTo < 0 {
output.Error("usage: hf schedule-type create <name> --work <from>-<to> --entertainment <from>-<to>\n e.g.: hf schedule-type create standard --work 8-18 --entertainment 19-23")
}
body := map[string]any{
"name": name,
"work_from": workFrom,
"work_to": workTo,
"entertainment_from": entFrom,
"entertainment_to": entTo,
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
jsonBody, _ := json.Marshal(body)
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/schedule-types/", bytes.NewReader(jsonBody))
if err != nil {
output.Errorf("failed to create schedule type: %v", err)
}
var resp scheduleTypeResponse
json.Unmarshal(data, &resp)
fmt.Printf("Created schedule type: %s (id=%d, work=%02d:00-%02d:00, entertainment=%02d:00-%02d:00)\n",
resp.Name, resp.ID, resp.WorkFrom, resp.WorkTo, resp.EntertainmentFrom, resp.EntertainmentTo)
}
// RunScheduleTypeDelete implements `hf schedule-type delete <id>`.
func RunScheduleTypeDelete(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf schedule-type delete <id>")
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/schedule-types/" + args[0])
if err != nil {
output.Errorf("failed to delete schedule type: %v", err)
}
fmt.Printf("Deleted schedule type %s\n", args[0])
}
// RunAssignScheduleType implements `hf assign-schedule-type <agent-id> <schedule-type-name>`.
func RunAssignScheduleType(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 2 {
output.Error("usage: hf assign-schedule-type <agent-id> <schedule-type-name>")
}
agentID := args[0]
scheduleName := args[1]
body := map[string]string{
"schedule_type_name": scheduleName,
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
jsonBody, _ := json.Marshal(body)
c := client.New(cfg.BaseURL, token)
data, err := c.Put("/schedule-types/agent/"+agentID+"/assign", bytes.NewReader(jsonBody))
if err != nil {
output.Errorf("failed to assign schedule type: %v", err)
}
var resp map[string]any
json.Unmarshal(data, &resp)
fmt.Printf("Assigned schedule type '%s' to agent '%s'\n", scheduleName, agentID)
}

View File

@@ -390,3 +390,60 @@ func RunUserDelete(username, tokenFlag string) {
} }
fmt.Printf("user deleted: %s\n", username) fmt.Printf("user deleted: %s\n", username)
} }
// resetAPIKeyResponse matches the backend reset-apikey response.
type resetAPIKeyResponse struct {
UserID int `json:"user_id"`
Username string `json:"username"`
APIKey string `json:"api_key"`
Message string `json:"message"`
}
// RunUserResetAPIKey implements `hf user reset-apikey <username>`.
func RunUserResetAPIKey(username, tokenFlag, accMgrTokenFlag string) {
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
// Try acc-mgr-token first (allows provisioning without existing user token)
var c *client.Client
if accMgrTokenFlag != "" {
c = client.NewWithAPIKey(cfg.BaseURL, accMgrTokenFlag)
} else if mode.IsPaddedCell() {
if tok, err := passmgr.GetAccountManagerToken(); err == nil && tok != "" {
c = client.NewWithAPIKey(cfg.BaseURL, tok)
} else {
token := ResolveToken(tokenFlag)
c = client.New(cfg.BaseURL, token)
}
} else {
token := ResolveToken(tokenFlag)
c = client.New(cfg.BaseURL, token)
}
data, err := c.Post("/users/"+username+"/reset-apikey", nil)
if err != nil {
output.Errorf("failed to reset API key: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var r resetAPIKeyResponse
if err := json.Unmarshal(data, &r); err != nil {
fmt.Printf("API key reset for: %s\n", username)
return
}
output.PrintKeyValue(
"username", r.Username,
"api-key", r.APIKey,
"message", r.Message,
)
}

View File

@@ -95,9 +95,9 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
Notes: []string{"Writes base-url into .hf-config.json next to the hf binary."}, Notes: []string{"Writes base-url into .hf-config.json next to the hf binary."},
}, },
"config/acc-mgr-token": { "config/acc-mgr-token": {
Summary: "Store the account-manager token via pass_mgr", Summary: "Store the account-manager token via secret-mgr",
Usage: []string{"hf config --acc-mgr-token <token>"}, Usage: []string{"hf config --acc-mgr-token <token>"},
Notes: []string{"Only available in padded-cell mode with pass_mgr installed."}, Notes: []string{"Only available in padded-cell mode with secret-mgr installed."},
}, },
"user/create": { "user/create": {
Summary: "Create a user account", Summary: "Create a user account",
@@ -105,7 +105,7 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
Flags: accountManagerFlagHelp(), Flags: accountManagerFlagHelp(),
Notes: []string{ Notes: []string{
"This command uses the account-manager token flow, not the normal user token flow.", "This command uses the account-manager token flow, not the normal user token flow.",
"In padded-cell mode, --acc-mgr-token is hidden and password generation can fall back to pass_mgr.", "In padded-cell mode, --acc-mgr-token is hidden and password generation can fall back to secret-mgr.",
}, },
}, },
"user/list": {Summary: "List users", Usage: []string{"hf user list"}, Flags: authFlagHelp()}, "user/list": {Summary: "List users", Usage: []string{"hf user list"}, Flags: authFlagHelp()},
@@ -114,6 +114,7 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
"user/activate": {Summary: "Activate a user", Usage: []string{"hf user activate <username>"}, Flags: authFlagHelp()}, "user/activate": {Summary: "Activate a user", Usage: []string{"hf user activate <username>"}, Flags: authFlagHelp()},
"user/deactivate": {Summary: "Deactivate a user", Usage: []string{"hf user deactivate <username>"}, Flags: authFlagHelp()}, "user/deactivate": {Summary: "Deactivate a user", Usage: []string{"hf user deactivate <username>"}, Flags: authFlagHelp()},
"user/delete": {Summary: "Delete a user", Usage: []string{"hf user delete <username>"}, Flags: authFlagHelp()}, "user/delete": {Summary: "Delete a user", Usage: []string{"hf user delete <username>"}, Flags: authFlagHelp()},
"user/reset-apikey": {Summary: "Reset a user's API key", Usage: []string{"hf user reset-apikey <username>"}, Flags: authFlagHelp(), Notes: []string{"The new API key is shown once and cannot be retrieved again."}},
"role/list": {Summary: "List roles", Usage: []string{"hf role list"}, Flags: authFlagHelp()}, "role/list": {Summary: "List roles", Usage: []string{"hf role list"}, Flags: authFlagHelp()},
"role/get": {Summary: "Show a role by name", Usage: []string{"hf role get <role-name>"}, Flags: authFlagHelp()}, "role/get": {Summary: "Show a role by name", Usage: []string{"hf role get <role-name>"}, Flags: authFlagHelp()},
"role/create": {Summary: "Create a role", Usage: []string{"hf role create --name <role-name> [--desc <desc>] [--global <true|false>]"}, Flags: authFlagHelp()}, "role/create": {Summary: "Create a role", Usage: []string{"hf role create --name <role-name> [--desc <desc>] [--global <true|false>]"}, Flags: authFlagHelp()},

View File

@@ -40,6 +40,7 @@ func CommandSurface() []Group {
{Name: "activate", Description: "Activate a user", Permitted: has(perms, "user.manage")}, {Name: "activate", Description: "Activate a user", Permitted: has(perms, "user.manage")},
{Name: "deactivate", Description: "Deactivate a user", Permitted: has(perms, "user.manage")}, {Name: "deactivate", Description: "Deactivate a user", Permitted: has(perms, "user.manage")},
{Name: "delete", Description: "Delete a user", Permitted: has(perms, "user.manage")}, {Name: "delete", Description: "Delete a user", Permitted: has(perms, "user.manage")},
{Name: "reset-apikey", Description: "Reset a user's API key", Permitted: true},
}, },
}, },
{ {
@@ -180,16 +181,6 @@ func CommandSurface() []Group {
{Name: "api-key", Description: "Manage monitor API keys", Permitted: has(perms, "monitor.manage")}, {Name: "api-key", Description: "Manage monitor API keys", Permitted: has(perms, "monitor.manage")},
}, },
}, },
{
Name: "schedule-type",
Description: "Manage work/entertainment schedule types",
SubCommands: []Command{
{Name: "list", Description: "List schedule types", Permitted: has(perms, "schedule_type.read")},
{Name: "create", Description: "Create a schedule type", Permitted: has(perms, "schedule_type.manage")},
{Name: "delete", Description: "Delete a schedule type", Permitted: has(perms, "schedule_type.manage")},
},
},
{Name: "assign-schedule-type", Description: "Assign a schedule type to an agent: assign-schedule-type <agent-id> <type-name>", Permitted: has(perms, "schedule_type.manage")},
} }
for i := range groups { for i := range groups {

View File

@@ -12,7 +12,7 @@ type RuntimeMode int
const ( const (
// ManualMode requires explicit --token / --acc-mgr-token flags. // ManualMode requires explicit --token / --acc-mgr-token flags.
ManualMode RuntimeMode = iota ManualMode RuntimeMode = iota
// PaddedCellMode resolves secrets via pass_mgr automatically. // PaddedCellMode resolves secrets via secret-mgr automatically.
PaddedCellMode PaddedCellMode
) )
@@ -21,11 +21,11 @@ var (
detectOnce sync.Once detectOnce sync.Once
) )
// Detect checks whether pass_mgr is available and returns the runtime mode. // Detect checks whether secret-mgr is available and returns the runtime mode.
// The result is cached after the first call. // The result is cached after the first call.
func Detect() RuntimeMode { func Detect() RuntimeMode {
detectOnce.Do(func() { detectOnce.Do(func() {
_, err := exec.LookPath("pass_mgr") _, err := exec.LookPath("secret-mgr")
if err == nil { if err == nil {
detectedMode = PaddedCellMode detectedMode = PaddedCellMode
} else { } else {

View File

@@ -1,4 +1,4 @@
// Package passmgr wraps calls to the pass_mgr binary for secret resolution. // Package passmgr wraps calls to the secret-mgr binary for secret resolution.
package passmgr package passmgr
import ( import (
@@ -7,49 +7,49 @@ import (
"strings" "strings"
) )
// GetSecret calls: pass_mgr get-secret [--public] --key <key> // GetSecret calls: secret-mgr get-secret [--public] --key <key>
func GetSecret(key string, public bool) (string, error) { func GetSecret(key string, public bool) (string, error) {
args := []string{"get-secret"} args := []string{"get-secret"}
if public { if public {
args = append(args, "--public") args = append(args, "--public")
} }
args = append(args, "--key", key) args = append(args, "--key", key)
out, err := exec.Command("pass_mgr", args...).Output() out, err := exec.Command("secret-mgr", args...).Output()
if err != nil { if err != nil {
return "", fmt.Errorf("pass_mgr get-secret --key %s failed: %w", key, err) return "", fmt.Errorf("secret-mgr get-secret --key %s failed: %w", key, err)
} }
return strings.TrimSpace(string(out)), nil return strings.TrimSpace(string(out)), nil
} }
// SetSecret calls: pass_mgr set [--public] --key <key> --secret <secret> // SetSecret calls: secret-mgr set [--public] --key <key> --secret <secret>
func SetSecret(key, secret string, public bool) error { func SetSecret(key, secret string, public bool) error {
args := []string{"set"} args := []string{"set"}
if public { if public {
args = append(args, "--public") args = append(args, "--public")
} }
args = append(args, "--key", key, "--secret", secret) args = append(args, "--key", key, "--secret", secret)
if err := exec.Command("pass_mgr", args...).Run(); err != nil { if err := exec.Command("secret-mgr", args...).Run(); err != nil {
return fmt.Errorf("pass_mgr set --key %s failed: %w", key, err) return fmt.Errorf("secret-mgr set --key %s failed: %w", key, err)
} }
return nil return nil
} }
// GeneratePassword calls: pass_mgr generate --key <key> --username <username> // GeneratePassword calls: secret-mgr generate --key <key> --username <username>
func GeneratePassword(key, username string) (string, error) { func GeneratePassword(key, username string) (string, error) {
args := []string{"generate", "--key", key, "--username", username} args := []string{"generate", "--key", key, "--username", username}
out, err := exec.Command("pass_mgr", args...).Output() out, err := exec.Command("secret-mgr", args...).Output()
if err != nil { if err != nil {
return "", fmt.Errorf("pass_mgr generate failed: %w", err) return "", fmt.Errorf("secret-mgr generate failed: %w", err)
} }
return strings.TrimSpace(string(out)), nil return strings.TrimSpace(string(out)), nil
} }
// GetToken retrieves the normal hf-token via pass_mgr. // GetToken retrieves the normal hf-token via secret-mgr.
func GetToken() (string, error) { func GetToken() (string, error) {
return GetSecret("hf-token", false) return GetSecret("hf-token", false)
} }
// GetAccountManagerToken retrieves the public hf-acc-mgr-token via pass_mgr. // GetAccountManagerToken retrieves the public hf-acc-mgr-token via secret-mgr.
func GetAccountManagerToken() (string, error) { func GetAccountManagerToken() (string, error) {
return GetSecret("hf-acc-mgr-token", true) return GetSecret("hf-acc-mgr-token", true)
} }