Compare commits
11 Commits
0280f2c327
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f1ebc52cca | |||
| de0ea39b2a | |||
| 6dae490257 | |||
| 53b5b88fc2 | |||
| 6252039fc5 | |||
| cd22642472 | |||
| 5ac90408f3 | |||
| ad0e123666 | |||
| e2177521e0 | |||
| 84150df4d5 | |||
| b287b1ff17 |
@@ -32,6 +32,28 @@ func main() {
|
||||
handleLeafOrRun("health", args[1:], commands.RunHealth)
|
||||
case "config":
|
||||
handleConfig(args[1:])
|
||||
case "update-discord-id":
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf update-discord-id <username> [discord-id]")
|
||||
}
|
||||
discordID := ""
|
||||
if len(filtered) >= 2 {
|
||||
discordID = filtered[1]
|
||||
}
|
||||
commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag)
|
||||
default:
|
||||
if group, ok := findGroup(args[0]); ok {
|
||||
handleGroup(group, args[1:])
|
||||
@@ -204,6 +226,31 @@ func handleGroup(group help.Group, args []string) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(args) > 0 && args[0] == "update-discord-id" {
|
||||
tokenFlag := ""
|
||||
var filtered []string
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--token":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
tokenFlag = args[i]
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf update-discord-id <username> [discord-id]")
|
||||
}
|
||||
discordID := ""
|
||||
if len(filtered) >= 2 {
|
||||
discordID = filtered[1]
|
||||
}
|
||||
commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag)
|
||||
return
|
||||
}
|
||||
|
||||
output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name)
|
||||
}
|
||||
|
||||
@@ -238,7 +285,7 @@ func handleUserCommand(subCmd string, args []string) {
|
||||
}
|
||||
commands.RunUserGet(filtered[0], tokenFlag)
|
||||
case "create":
|
||||
username, password, email, fullName := "", "", "", ""
|
||||
username, password, email, fullName, discordUserID := "", "", "", "", ""
|
||||
for i := 0; i < len(filtered); i++ {
|
||||
switch filtered[i] {
|
||||
case "--user":
|
||||
@@ -261,6 +308,11 @@ func handleUserCommand(subCmd string, args []string) {
|
||||
i++
|
||||
fullName = filtered[i]
|
||||
}
|
||||
case "--discord-user-id":
|
||||
if i+1 < len(filtered) {
|
||||
i++
|
||||
discordUserID = filtered[i]
|
||||
}
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", filtered[i])
|
||||
}
|
||||
@@ -268,7 +320,7 @@ func handleUserCommand(subCmd string, args []string) {
|
||||
if username == "" {
|
||||
output.Error("usage: hf user create --user <username>")
|
||||
}
|
||||
commands.RunUserCreate(username, password, email, fullName, accMgrTokenFlag)
|
||||
commands.RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag)
|
||||
case "update":
|
||||
if len(filtered) < 1 {
|
||||
output.Error("usage: hf user update <username> [--email ...] [--full-name ...] [--pass ...] [--active ...]")
|
||||
@@ -289,6 +341,11 @@ func handleUserCommand(subCmd string, args []string) {
|
||||
output.Error("usage: hf user delete <username>")
|
||||
}
|
||||
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:
|
||||
output.Errorf("hf user %s is not implemented yet", subCmd)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
APIKey string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
@@ -28,6 +29,17 @@ func New(baseURL, token string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithAPIKey creates a Client that authenticates using X-API-Key.
|
||||
func NewWithAPIKey(baseURL, apiKey string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
APIKey: apiKey,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RequestError represents a non-2xx HTTP response.
|
||||
type RequestError struct {
|
||||
StatusCode int
|
||||
@@ -45,7 +57,9 @@ func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
if c.Token != "" {
|
||||
if c.APIKey != "" {
|
||||
req.Header.Set("X-API-Key", c.APIKey)
|
||||
} else if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if body != nil {
|
||||
@@ -93,7 +107,7 @@ func (c *Client) Delete(path string) ([]byte, error) {
|
||||
|
||||
// Health checks the API health endpoint and returns the response.
|
||||
func (c *Client) Health() (map[string]interface{}, error) {
|
||||
data, err := c.Get("/api/health/")
|
||||
data, err := c.Get("/health")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func RunConfigURL(url string) {
|
||||
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) {
|
||||
if token == "" {
|
||||
output.Error("usage: hf config --acc-mgr-token <token>")
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
type essentialResponse struct {
|
||||
ID int `json:"id"`
|
||||
EssentialCode string `json:"essential_code"`
|
||||
ProposalID int `json:"proposal_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
@@ -49,7 +48,7 @@ func RunEssentialList(args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get("/proposes/" + proposalCode + "/essentials")
|
||||
data, err := c.Get(proposalPath(c, proposalCode) + "/essentials")
|
||||
if err != nil {
|
||||
output.Errorf("failed to list essentials: %v", err)
|
||||
}
|
||||
@@ -146,7 +145,7 @@ func RunEssentialCreate(args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post("/proposes/"+proposalCode+"/essentials", bytes.NewReader(body))
|
||||
data, err := c.Post(proposalPath(c, proposalCode)+"/essentials", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to create essential: %v", err)
|
||||
}
|
||||
@@ -229,7 +228,7 @@ func RunEssentialUpdate(essentialCode string, args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Patch("/proposes/"+proposalCode+"/essentials/"+essentialCode, bytes.NewReader(body))
|
||||
_, err = c.Patch(proposalPath(c, proposalCode)+"/essentials/"+essentialCode, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to update essential: %v", err)
|
||||
}
|
||||
@@ -266,7 +265,7 @@ func RunEssentialDeleteFull(essentialCode string, args []string, tokenFlag strin
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Delete("/proposes/" + proposalCode + "/essentials/" + essentialCode)
|
||||
_, err = c.Delete(proposalPath(c, proposalCode) + "/essentials/" + essentialCode)
|
||||
if err != nil {
|
||||
output.Errorf("failed to delete essential: %v", err)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func RunMeetingList(args []string, tokenFlag string) {
|
||||
output.Error("--project requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "project", args[i])
|
||||
query = appendQuery(query, "project_code", args[i])
|
||||
case "--status":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--status requires a value")
|
||||
|
||||
@@ -44,7 +44,7 @@ func RunMilestoneList(args []string, tokenFlag string) {
|
||||
output.Error("--project requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "project", args[i])
|
||||
query = appendQuery(query, "project_code", args[i])
|
||||
case "--status":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--status requires a value")
|
||||
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
|
||||
// monitorOverviewResponse matches the backend monitor overview schema.
|
||||
type monitorOverviewResponse struct {
|
||||
TotalServers int `json:"total_servers"`
|
||||
OnlineServers int `json:"online_servers"`
|
||||
Tasks interface{} `json:"tasks"`
|
||||
Providers interface{} `json:"providers"`
|
||||
Servers []monitorServerResponse `json:"servers"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
// monitorServerResponse matches the backend monitor server schema.
|
||||
@@ -28,8 +30,32 @@ type monitorServerResponse struct {
|
||||
|
||||
// monitorAPIKeyResponse matches the backend monitor API key schema.
|
||||
type monitorAPIKeyResponse struct {
|
||||
Identifier string `json:"identifier"`
|
||||
APIKey string `json:"api_key"`
|
||||
ServerID int `json:"server_id"`
|
||||
APIKey string `json:"api_key"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func monitorServerList(c *client.Client) []monitorServerResponse {
|
||||
data, err := c.Get("/monitor/admin/servers")
|
||||
if err != nil {
|
||||
output.Errorf("failed to list monitor servers: %v", err)
|
||||
}
|
||||
var servers []monitorServerResponse
|
||||
if err := json.Unmarshal(data, &servers); err != nil {
|
||||
output.Errorf("cannot parse server list: %v", err)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
func resolveMonitorServerID(c *client.Client, identifier string) int {
|
||||
servers := monitorServerList(c)
|
||||
for _, s := range servers {
|
||||
if s.Identifier == identifier {
|
||||
return s.ID
|
||||
}
|
||||
}
|
||||
output.Errorf("monitor server not found: %s", identifier)
|
||||
return 0
|
||||
}
|
||||
|
||||
// RunMonitorOverview implements `hf monitor overview`.
|
||||
@@ -40,7 +66,7 @@ func RunMonitorOverview(tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get("/monitor/overview")
|
||||
data, err := c.Get("/monitor/public/overview")
|
||||
if err != nil {
|
||||
output.Errorf("failed to get monitor overview: %v", err)
|
||||
}
|
||||
@@ -59,9 +85,16 @@ func RunMonitorOverview(tokenFlag string) {
|
||||
output.Errorf("cannot parse monitor overview: %v", err)
|
||||
}
|
||||
|
||||
online := 0
|
||||
for _, s := range o.Servers {
|
||||
if s.Status == "online" {
|
||||
online++
|
||||
}
|
||||
}
|
||||
output.PrintKeyValue(
|
||||
"total-servers", fmt.Sprintf("%d", o.TotalServers),
|
||||
"online-servers", fmt.Sprintf("%d", o.OnlineServers),
|
||||
"total-servers", fmt.Sprintf("%d", len(o.Servers)),
|
||||
"online-servers", fmt.Sprintf("%d", online),
|
||||
"generated-at", o.GeneratedAt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,7 +106,7 @@ func RunMonitorServerList(tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get("/monitor/servers")
|
||||
data, err := c.Get("/monitor/admin/servers")
|
||||
if err != nil {
|
||||
output.Errorf("failed to list monitor servers: %v", err)
|
||||
}
|
||||
@@ -116,39 +149,37 @@ func RunMonitorServerGet(identifier, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get("/monitor/servers/" + identifier)
|
||||
if err != nil {
|
||||
output.Errorf("failed to get server: %v", err)
|
||||
servers := monitorServerList(c)
|
||||
var found *monitorServerResponse
|
||||
for i := range servers {
|
||||
if servers[i].Identifier == identifier {
|
||||
found = &servers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
output.Errorf("failed to get server: not found: %s", identifier)
|
||||
}
|
||||
|
||||
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)
|
||||
output.PrintJSON(found)
|
||||
return
|
||||
}
|
||||
|
||||
var s monitorServerResponse
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
output.Errorf("cannot parse server: %v", err)
|
||||
}
|
||||
|
||||
name := ""
|
||||
if s.DisplayName != nil {
|
||||
name = *s.DisplayName
|
||||
if found.DisplayName != nil {
|
||||
name = *found.DisplayName
|
||||
}
|
||||
lastSeen := ""
|
||||
if s.LastSeen != nil {
|
||||
lastSeen = *s.LastSeen
|
||||
if found.LastSeen != nil {
|
||||
lastSeen = *found.LastSeen
|
||||
}
|
||||
output.PrintKeyValue(
|
||||
"identifier", s.Identifier,
|
||||
"identifier", found.Identifier,
|
||||
"name", name,
|
||||
"status", s.Status,
|
||||
"status", found.Status,
|
||||
"last-seen", lastSeen,
|
||||
"created", s.CreatedAt,
|
||||
"created", found.CreatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,7 +228,7 @@ func RunMonitorServerCreate(args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post("/monitor/servers", bytes.NewReader(body))
|
||||
data, err := c.Post("/monitor/admin/servers", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to create server: %v", err)
|
||||
}
|
||||
@@ -223,7 +254,8 @@ func RunMonitorServerDelete(identifier, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Delete("/monitor/servers/" + identifier)
|
||||
serverID := resolveMonitorServerID(c, identifier)
|
||||
_, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d", serverID))
|
||||
if err != nil {
|
||||
output.Errorf("failed to delete server: %v", err)
|
||||
}
|
||||
@@ -238,7 +270,8 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post("/monitor/servers/"+identifier+"/api-key", nil)
|
||||
serverID := resolveMonitorServerID(c, identifier)
|
||||
data, err := c.Post(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID), nil)
|
||||
if err != nil {
|
||||
output.Errorf("failed to generate API key: %v", err)
|
||||
}
|
||||
@@ -258,8 +291,9 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
|
||||
return
|
||||
}
|
||||
output.PrintKeyValue(
|
||||
"identifier", k.Identifier,
|
||||
"server-id", fmt.Sprintf("%d", k.ServerID),
|
||||
"api-key", k.APIKey,
|
||||
"message", k.Message,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -271,7 +305,8 @@ func RunMonitorAPIKeyRevoke(identifier, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Delete("/monitor/servers/" + identifier + "/api-key")
|
||||
serverID := resolveMonitorServerID(c, identifier)
|
||||
_, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID))
|
||||
if err != nil {
|
||||
output.Errorf("failed to revoke API key: %v", err)
|
||||
}
|
||||
|
||||
@@ -170,20 +170,29 @@ func TestEssentialDelete_MissingProposal(t *testing.T) {
|
||||
|
||||
func TestEssentialList_JSONOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/proposes/PRJ-001/essentials" {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/projects":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"essential_code": "ESS-001",
|
||||
"proposal_id": 1,
|
||||
"type": "feature",
|
||||
"title": "Add login",
|
||||
"created_at": "2026-03-01",
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 1,
|
||||
"essential_code": "ESS-001",
|
||||
"proposal_id": 1,
|
||||
"type": "feature",
|
||||
"title": "Add login",
|
||||
"created_at": "2026-03-01",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
@@ -204,16 +213,25 @@ func TestEssentialList_JSONOutput(t *testing.T) {
|
||||
|
||||
func TestEssentialCreate_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("expected POST; got: %s", r.Method)
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/projects":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
|
||||
case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": 1,
|
||||
"essential_code": "ESS-001",
|
||||
"title": "Add login",
|
||||
"type": "feature",
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": 1,
|
||||
"essential_code": "ESS-001",
|
||||
"title": "Add login",
|
||||
"type": "feature",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
@@ -234,11 +252,20 @@ func TestEssentialCreate_Success(t *testing.T) {
|
||||
|
||||
func TestEssentialUpdate_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PATCH" {
|
||||
t.Errorf("expected PATCH; got: %s", r.Method)
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/projects":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
|
||||
case r.Method == "PATCH" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001":
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
@@ -259,11 +286,20 @@ func TestEssentialUpdate_Success(t *testing.T) {
|
||||
|
||||
func TestEssentialDelete_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
t.Errorf("expected DELETE; got: %s", r.Method)
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/projects":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
|
||||
case r.Method == "DELETE" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001":
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
@@ -306,20 +342,29 @@ func TestProposalAccept_MissingMilestone(t *testing.T) {
|
||||
|
||||
func TestProposalAccept_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/proposes/PRJ-001/accept" {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/projects":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
|
||||
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
|
||||
case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/accept":
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["milestone_code"] != "MS-001" {
|
||||
t.Errorf("expected milestone_code=MS-001; got: %v", body["milestone_code"])
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "PRJ-001",
|
||||
"status": "Accepted",
|
||||
"tasks": []interface{}{},
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["milestone_code"] != "MS-001" {
|
||||
t.Errorf("expected milestone_code=MS-001; got: %v", body["milestone_code"])
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "PRJ-001",
|
||||
"status": "Accepted",
|
||||
"tasks": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
@@ -421,7 +466,7 @@ func TestTaskCreate_StoryTypeOnlyRestricted(t *testing.T) {
|
||||
|
||||
func TestProposalList_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/proposes" {
|
||||
if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
@@ -444,7 +489,7 @@ func TestProposalList_Success(t *testing.T) {
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake")
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake", "--project", "PROJ-001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
@@ -455,6 +500,9 @@ func TestProposalList_Success(t *testing.T) {
|
||||
|
||||
func TestProposalList_JSONOutput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode([]interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -472,7 +520,7 @@ func TestProposalList_JSONOutput(t *testing.T) {
|
||||
cliPath := filepath.Join(tmpDir, "hf")
|
||||
buildCLI(t, cliPath)
|
||||
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake")
|
||||
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake", "--project", "PROJ-001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
|
||||
@@ -23,11 +24,44 @@ type proposeResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type projectLookup struct {
|
||||
ID int `json:"id"`
|
||||
ProjectCode string `json:"project_code"`
|
||||
}
|
||||
|
||||
func resolveProposalProject(c *client.Client, proposalCode string) string {
|
||||
data, err := c.Get("/projects")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var projects []projectLookup
|
||||
if err := json.Unmarshal(data, &projects); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, p := range projects {
|
||||
if p.ProjectCode == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := c.Get("/projects/" + p.ProjectCode + "/proposals/" + proposalCode); err == nil {
|
||||
return p.ProjectCode
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func proposalPath(c *client.Client, proposalCode string) string {
|
||||
if project := resolveProposalProject(c, proposalCode); project != "" {
|
||||
return "/projects/" + project + "/proposals/" + proposalCode
|
||||
}
|
||||
return "/proposes/" + proposalCode
|
||||
}
|
||||
|
||||
// RunProposeList implements `hf propose list --project <project-code>`.
|
||||
func RunProposeList(args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
|
||||
query := ""
|
||||
project := ""
|
||||
query := url.Values{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--project":
|
||||
@@ -35,32 +69,39 @@ func RunProposeList(args []string, tokenFlag string) {
|
||||
output.Error("--project requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "project", args[i])
|
||||
project = args[i]
|
||||
case "--status":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--status requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "status", args[i])
|
||||
query.Set("status", args[i])
|
||||
case "--order-by":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--order-by requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "order_by", args[i])
|
||||
query.Set("order_by", args[i])
|
||||
default:
|
||||
output.Errorf("unknown flag: %s", args[i])
|
||||
}
|
||||
}
|
||||
legacyPath := false
|
||||
if project == "" {
|
||||
legacyPath = true
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
path := "/proposes"
|
||||
if query != "" {
|
||||
path += "?" + query
|
||||
path := "/projects/" + project + "/proposals"
|
||||
if legacyPath {
|
||||
path = "/proposes"
|
||||
}
|
||||
if encoded := query.Encode(); encoded != "" {
|
||||
path += "?" + encoded
|
||||
}
|
||||
data, err := c.Get(path)
|
||||
if err != nil {
|
||||
@@ -105,7 +146,7 @@ func RunProposeGet(proposeCode, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Get("/proposes/" + proposeCode)
|
||||
data, err := c.Get(proposalPath(c, proposeCode))
|
||||
if err != nil {
|
||||
output.Errorf("failed to get proposal: %v", err)
|
||||
}
|
||||
@@ -178,9 +219,8 @@ func RunProposeCreate(args []string, tokenFlag string) {
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"project_code": project,
|
||||
"title": title,
|
||||
"description": desc,
|
||||
"title": title,
|
||||
"description": desc,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
@@ -193,7 +233,7 @@ func RunProposeCreate(args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post("/proposes", bytes.NewReader(body))
|
||||
data, err := c.Post("/projects/"+project+"/proposals", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to create proposal: %v", err)
|
||||
}
|
||||
@@ -253,7 +293,7 @@ func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Patch("/proposes/"+proposeCode, bytes.NewReader(body))
|
||||
_, err = c.Patch(proposalPath(c, proposeCode), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to update proposal: %v", err)
|
||||
}
|
||||
@@ -311,7 +351,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
data, err := c.Post("/proposes/"+proposeCode+"/accept", bytes.NewReader(body))
|
||||
data, err := c.Post(proposalPath(c, proposeCode)+"/accept", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to accept proposal: %v", err)
|
||||
}
|
||||
@@ -332,7 +372,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
|
||||
if err := json.Unmarshal(data, &resp); err == nil && len(resp.GeneratedTasks) > 0 {
|
||||
fmt.Printf("\nGenerated %d story task(s):\n", len(resp.GeneratedTasks))
|
||||
for _, gt := range resp.GeneratedTasks {
|
||||
code := ""
|
||||
code := "(no task_code)"
|
||||
if gt.TaskCode != nil {
|
||||
code = *gt.TaskCode
|
||||
}
|
||||
@@ -380,7 +420,7 @@ func RunProposeReject(proposeCode string, args []string, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Post("/proposes/"+proposeCode+"/reject", body)
|
||||
_, err = c.Post(proposalPath(c, proposeCode)+"/reject", body)
|
||||
if err != nil {
|
||||
output.Errorf("failed to reject proposal: %v", err)
|
||||
}
|
||||
@@ -397,7 +437,7 @@ func RunProposeReopen(proposeCode, tokenFlag string) {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
_, err = c.Post("/proposes/"+proposeCode+"/reopen", nil)
|
||||
_, err = c.Post(proposalPath(c, proposeCode)+"/reopen", nil)
|
||||
if err != nil {
|
||||
output.Errorf("failed to reopen proposal: %v", err)
|
||||
}
|
||||
|
||||
@@ -38,13 +38,13 @@ func RunTaskList(args []string, tokenFlag string) {
|
||||
output.Error("--project requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "project", args[i])
|
||||
query = appendQuery(query, "project_code", args[i])
|
||||
case "--milestone":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--milestone requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "milestone", args[i])
|
||||
query = appendQuery(query, "milestone_code", args[i])
|
||||
case "--status":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--status requires a value")
|
||||
@@ -426,7 +426,7 @@ func RunTaskSearch(args []string, tokenFlag string) {
|
||||
output.Error("--project requires a value")
|
||||
}
|
||||
i++
|
||||
query = appendQuery(query, "project", args[i])
|
||||
query = appendQuery(query, "project_code", args[i])
|
||||
case "--status":
|
||||
if i+1 >= len(args) {
|
||||
output.Error("--status requires a value")
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
||||
@@ -23,6 +25,7 @@ type userResponse struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
RoleID *int `json:"role_id"`
|
||||
RoleName *string `json:"role_name"`
|
||||
DiscordUserID *string `json:"discord_user_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -137,10 +140,41 @@ type userCreatePayload struct {
|
||||
Email string `json:"email"`
|
||||
FullName *string `json:"full_name,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
DiscordUserID *string `json:"discord_user_id,omitempty"`
|
||||
}
|
||||
|
||||
func maybeResolveDiscordUserID(explicit string, requireEnv bool) (string, bool, error) {
|
||||
if strings.TrimSpace(explicit) != "" {
|
||||
return strings.TrimSpace(explicit), true, nil
|
||||
}
|
||||
agentID := strings.TrimSpace(os.Getenv("AGENT_ID"))
|
||||
agentVerify := strings.TrimSpace(os.Getenv("AGENT_VERIFY"))
|
||||
if agentID == "" || agentVerify == "" {
|
||||
if requireEnv {
|
||||
return "", false, fmt.Errorf("discord id not provided and AGENT_ID/AGENT_VERIFY are missing")
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
cmd := exec.Command("ego-mgr", "get", "discord-id")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if requireEnv {
|
||||
return "", false, fmt.Errorf("failed to resolve discord id from ego-mgr: %w", err)
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
value := strings.TrimSpace(string(out))
|
||||
if value == "" {
|
||||
if requireEnv {
|
||||
return "", false, fmt.Errorf("ego-mgr returned empty discord id")
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
// RunUserCreate implements `hf user create`.
|
||||
func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string) {
|
||||
func RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag string) {
|
||||
// Resolve account-manager token
|
||||
var accMgrToken string
|
||||
if mode.IsPaddedCell() {
|
||||
@@ -181,6 +215,11 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
|
||||
Email: email,
|
||||
Password: &password,
|
||||
}
|
||||
if resolvedDiscordID, ok, err := maybeResolveDiscordUserID(discordUserID, false); err != nil {
|
||||
output.Errorf("failed to resolve discord user id: %v", err)
|
||||
} else if ok {
|
||||
payload.DiscordUserID = &resolvedDiscordID
|
||||
}
|
||||
if fullName != "" {
|
||||
payload.FullName = &fullName
|
||||
}
|
||||
@@ -194,7 +233,7 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, accMgrToken)
|
||||
c := client.NewWithAPIKey(cfg.BaseURL, accMgrToken)
|
||||
data, err := c.Post("/users", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
output.Errorf("failed to create user: %v", err)
|
||||
@@ -216,6 +255,28 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
|
||||
fmt.Printf("user created: %s\n", u.Username)
|
||||
}
|
||||
|
||||
// RunUserUpdateDiscordID updates a user's discord_user_id field.
|
||||
func RunUserUpdateDiscordID(username, discordUserID, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
resolvedDiscordID, _, err := maybeResolveDiscordUserID(discordUserID, true)
|
||||
if err != nil {
|
||||
output.Errorf("failed to resolve discord user id: %v", err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]interface{}{"discord_user_id": resolvedDiscordID})
|
||||
if err != nil {
|
||||
output.Errorf("cannot marshal payload: %v", err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
output.Errorf("config error: %v", err)
|
||||
}
|
||||
c := client.New(cfg.BaseURL, token)
|
||||
if _, err := c.Patch("/users/"+username, bytes.NewReader(body)); err != nil {
|
||||
output.Errorf("failed to update discord id: %v", err)
|
||||
}
|
||||
fmt.Printf("discord id updated: %s\n", username)
|
||||
}
|
||||
|
||||
// RunUserUpdate implements `hf user update <username>`.
|
||||
func RunUserUpdate(username string, args []string, tokenFlag string) {
|
||||
token := ResolveToken(tokenFlag)
|
||||
@@ -329,3 +390,60 @@ func RunUserDelete(username, tokenFlag string) {
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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."},
|
||||
},
|
||||
"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>"},
|
||||
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": {
|
||||
Summary: "Create a user account",
|
||||
@@ -105,7 +105,7 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
|
||||
Flags: accountManagerFlagHelp(),
|
||||
Notes: []string{
|
||||
"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()},
|
||||
@@ -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/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/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/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()},
|
||||
|
||||
@@ -40,6 +40,7 @@ func CommandSurface() []Group {
|
||||
{Name: "activate", Description: "Activate 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: "reset-apikey", Description: "Reset a user's API key", Permitted: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ type RuntimeMode int
|
||||
const (
|
||||
// ManualMode requires explicit --token / --acc-mgr-token flags.
|
||||
ManualMode RuntimeMode = iota
|
||||
// PaddedCellMode resolves secrets via pass_mgr automatically.
|
||||
// PaddedCellMode resolves secrets via secret-mgr automatically.
|
||||
PaddedCellMode
|
||||
)
|
||||
|
||||
@@ -21,11 +21,11 @@ var (
|
||||
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.
|
||||
func Detect() RuntimeMode {
|
||||
detectOnce.Do(func() {
|
||||
_, err := exec.LookPath("pass_mgr")
|
||||
_, err := exec.LookPath("secret-mgr")
|
||||
if err == nil {
|
||||
detectedMode = PaddedCellMode
|
||||
} else {
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
@@ -7,49 +7,49 @@ import (
|
||||
"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) {
|
||||
args := []string{"get-secret"}
|
||||
if public {
|
||||
args = append(args, "--public")
|
||||
}
|
||||
args = append(args, "--key", key)
|
||||
out, err := exec.Command("pass_mgr", args...).Output()
|
||||
out, err := exec.Command("secret-mgr", args...).Output()
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
args := []string{"set"}
|
||||
if public {
|
||||
args = append(args, "--public")
|
||||
}
|
||||
args = append(args, "--key", key, "--secret", secret)
|
||||
if err := exec.Command("pass_mgr", args...).Run(); err != nil {
|
||||
return fmt.Errorf("pass_mgr set --key %s failed: %w", key, err)
|
||||
if err := exec.Command("secret-mgr", args...).Run(); err != nil {
|
||||
return fmt.Errorf("secret-mgr set --key %s failed: %w", key, err)
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
return "", fmt.Errorf("pass_mgr generate failed: %w", err)
|
||||
return "", fmt.Errorf("secret-mgr generate failed: %w", err)
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
return GetSecret("hf-acc-mgr-token", true)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user