Compare commits
26 Commits
0280f2c327
...
fix/securi
| Author | SHA1 | Date | |
|---|---|---|---|
| 4125a4c102 | |||
| c0ab087436 | |||
| 3edabb72ba | |||
| 1c9e90b033 | |||
| 2176383729 | |||
| a42ba6f880 | |||
| b0f4aa286b | |||
|
|
46d928782b | ||
| e99b12ef08 | |||
|
|
6ace6f2594 | ||
| 8dd58bad43 | |||
| 7ed99d7347 | |||
| 34a5512009 | |||
| ce532bdf15 | |||
| dbc599171f | |||
| f1ebc52cca | |||
| de0ea39b2a | |||
| 6dae490257 | |||
| 53b5b88fc2 | |||
| 6252039fc5 | |||
| cd22642472 | |||
| 5ac90408f3 | |||
| ad0e123666 | |||
| e2177521e0 | |||
| 84150df4d5 | |||
| b287b1ff17 |
15
README.md
15
README.md
@@ -1,6 +1,8 @@
|
|||||||
# HarborForge.Cli
|
# HarborForge.Cli
|
||||||
|
|
||||||
`HarborForge.Cli` is the Go-based `hf` binary for HarborForge.
|
`HarborForge.Cli` is the Go-based `hf` command-line client for HarborForge.
|
||||||
|
|
||||||
|
Part of the [HarborForge](../README.md) platform. `hf` is a thin, scriptable client over the `HarborForge.Backend` REST API (default `http://127.0.0.1:8000`). It is permission-aware (command visibility derives from the caller's backend permissions) and supports both automatic secret resolution (padded-cell mode) and explicit `--token` auth (manual mode).
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -205,14 +207,15 @@ There is not yet a finer-grained exit-code taxonomy; callers should currently tr
|
|||||||
- Top-level and group/leaf help rendering (`--help` / `--help-brief`)
|
- Top-level and group/leaf help rendering (`--help` / `--help-brief`)
|
||||||
- Permission-aware command visibility via `/auth/me/permissions`
|
- Permission-aware command visibility via `/auth/me/permissions`
|
||||||
- Detailed leaf help text for all commands, with padded-cell/manual auth flag differences
|
- Detailed leaf help text for all commands, with padded-cell/manual auth flag differences
|
||||||
- Nested help coverage for `config`, `monitor server`, and `monitor api-key` subtrees
|
- Nested help coverage for `config`, `monitor server`, `monitor api-key`, and `proposal essential` subtrees
|
||||||
- `(not permitted)` rendering for unauthorized commands
|
- `(not permitted)` rendering for unauthorized commands
|
||||||
|
|
||||||
**Core commands:**
|
**Core commands:**
|
||||||
- `hf version`, `hf health`, `hf config` (show / `--url` / `--acc-mgr-token`)
|
- `hf version`, `hf health`, `hf config` (show / `--url` / `--acc-mgr-token`)
|
||||||
|
- `hf update-discord-id <username> [discord-id]` — top-level convenience command
|
||||||
|
|
||||||
**Resource commands (all implemented with list/get/create/update/delete + special actions):**
|
**Resource commands (all implemented with list/get/create/update/delete + special actions):**
|
||||||
- `hf user` — create, list, get, update, activate, deactivate, delete
|
- `hf user` — create, list, get, update, activate, deactivate, delete, reset-apikey
|
||||||
- `hf role` — list, get, create, update, delete, set-permissions, add-permissions, remove-permissions
|
- `hf role` — list, get, create, update, delete, set-permissions, add-permissions, remove-permissions
|
||||||
- `hf permission` — list
|
- `hf permission` — list
|
||||||
- `hf project` — list, get, create, update, delete, members, add-member, remove-member
|
- `hf project` — list, get, create, update, delete, members, add-member, remove-member
|
||||||
@@ -220,7 +223,9 @@ There is not yet a finer-grained exit-code taxonomy; callers should currently tr
|
|||||||
- `hf task` — list, get, create, update, transition, take, delete, search
|
- `hf task` — list, get, create, update, transition, take, delete, search
|
||||||
- `hf meeting` — list, get, create, update, attend, delete
|
- `hf meeting` — list, get, create, update, attend, delete
|
||||||
- `hf support` — list, get, create, update, take, transition, delete
|
- `hf support` — list, get, create, update, take, transition, delete
|
||||||
- `hf propose` — list, get, create, update, accept, reject, reopen
|
- `hf proposal` (alias: `hf propose`) — list, get, create, update, accept, reject, reopen
|
||||||
|
- `hf proposal essential` — list, create, update, delete
|
||||||
|
- `hf calendar` — schedule, show, edit, cancel, date-list, plan-schedule, plan-list, plan-edit, plan-cancel
|
||||||
- `hf comment` — add, list
|
- `hf comment` — add, list
|
||||||
- `hf worklog` — add, list
|
- `hf worklog` — add, list
|
||||||
- `hf monitor` — overview, server (list/get/create/delete), api-key (generate/revoke)
|
- `hf monitor` — overview, server (list/get/create/delete), api-key (generate/revoke)
|
||||||
@@ -229,4 +234,4 @@ There is not yet a finer-grained exit-code taxonomy; callers should currently tr
|
|||||||
|
|
||||||
- Backend code-based endpoint support (some commands still use id-based API routes)
|
- Backend code-based endpoint support (some commands still use id-based API routes)
|
||||||
- Release automation beyond local `make release` packaging (checksums / archives / CI publishing)
|
- Release automation beyond local `make release` packaging (checksums / archives / CI publishing)
|
||||||
- Integration tests
|
- Broader test coverage (unit tests exist for the calendar and proposal commands; end-to-end coverage is still partial)
|
||||||
|
|||||||
173
cmd/hf/main.go
173
cmd/hf/main.go
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands"
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands"
|
||||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/help"
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/help"
|
||||||
@@ -32,6 +33,46 @@ func main() {
|
|||||||
handleLeafOrRun("health", args[1:], commands.RunHealth)
|
handleLeafOrRun("health", args[1:], commands.RunHealth)
|
||||||
case "config":
|
case "config":
|
||||||
handleConfig(args[1:])
|
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)
|
||||||
|
case "agent":
|
||||||
|
// `hf agent <sub>` — currently only `status` is implemented (wraps
|
||||||
|
// `POST /calendar/agent/status`, identifies caller via
|
||||||
|
// AGENT_ID/CLAW_IDENTIFIER env, no token needed).
|
||||||
|
if len(args) < 2 {
|
||||||
|
output.Error("usage: hf agent status --set <idle|busy|on_call|exhausted|offline>")
|
||||||
|
}
|
||||||
|
switch args[1] {
|
||||||
|
case "status":
|
||||||
|
commands.RunAgentStatus(args[2:])
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown agent subcommand: %s", args[1])
|
||||||
|
}
|
||||||
|
case "assign-schedule-type":
|
||||||
|
// Leaf command (no subcommands) — args are <agent-id> <type-name>.
|
||||||
|
// Must dispatch at top level because handleGroup treats args[0] as
|
||||||
|
// a subcommand name and would error "unknown ... subcommand: <agent-id>".
|
||||||
|
handleAssignScheduleType(args[1:])
|
||||||
default:
|
default:
|
||||||
if group, ok := findGroup(args[0]); ok {
|
if group, ok := findGroup(args[0]); ok {
|
||||||
handleGroup(group, args[1:])
|
handleGroup(group, args[1:])
|
||||||
@@ -202,6 +243,37 @@ 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" {
|
||||||
|
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)
|
output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name)
|
||||||
@@ -238,7 +310,8 @@ func handleUserCommand(subCmd string, args []string) {
|
|||||||
}
|
}
|
||||||
commands.RunUserGet(filtered[0], tokenFlag)
|
commands.RunUserGet(filtered[0], tokenFlag)
|
||||||
case "create":
|
case "create":
|
||||||
username, password, email, fullName := "", "", "", ""
|
username, password, email, fullName, discordUserID := "", "", "", "", ""
|
||||||
|
agentID, clawID := "", ""
|
||||||
for i := 0; i < len(filtered); i++ {
|
for i := 0; i < len(filtered); i++ {
|
||||||
switch filtered[i] {
|
switch filtered[i] {
|
||||||
case "--user":
|
case "--user":
|
||||||
@@ -261,14 +334,59 @@ func handleUserCommand(subCmd string, args []string) {
|
|||||||
i++
|
i++
|
||||||
fullName = filtered[i]
|
fullName = filtered[i]
|
||||||
}
|
}
|
||||||
|
case "--discord-user-id":
|
||||||
|
if i+1 < len(filtered) {
|
||||||
|
i++
|
||||||
|
discordUserID = filtered[i]
|
||||||
|
}
|
||||||
|
case "--agent-id":
|
||||||
|
if i+1 < len(filtered) {
|
||||||
|
i++
|
||||||
|
agentID = filtered[i]
|
||||||
|
}
|
||||||
|
case "--claw-identifier":
|
||||||
|
if i+1 < len(filtered) {
|
||||||
|
i++
|
||||||
|
clawID = filtered[i]
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
output.Errorf("unknown flag: %s", filtered[i])
|
output.Errorf("unknown flag: %s", filtered[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if username == "" {
|
if username == "" {
|
||||||
output.Error("usage: hf user create --user <username>")
|
output.Error("usage: hf user create --user <username> [--agent-id <id> --claw-identifier <id>]")
|
||||||
}
|
}
|
||||||
commands.RunUserCreate(username, password, email, fullName, accMgrTokenFlag)
|
commands.RunUserCreate(username, password, email, fullName, discordUserID, agentID, clawID, accMgrTokenFlag)
|
||||||
|
case "bind-agent":
|
||||||
|
// `hf user bind-agent <username> [--agent-id <id>] [--claw-identifier <id>]`
|
||||||
|
// Backfills the agents row for an existing user. Pcexec env
|
||||||
|
// (AGENT_ID + `openclaw config get plugins.harbor-forge.identifier`)
|
||||||
|
// covers the flags when run from a pcexec session.
|
||||||
|
username, agentID, clawID := "", "", ""
|
||||||
|
for i := 0; i < len(filtered); i++ {
|
||||||
|
switch filtered[i] {
|
||||||
|
case "--agent-id":
|
||||||
|
if i+1 < len(filtered) {
|
||||||
|
i++
|
||||||
|
agentID = filtered[i]
|
||||||
|
}
|
||||||
|
case "--claw-identifier":
|
||||||
|
if i+1 < len(filtered) {
|
||||||
|
i++
|
||||||
|
clawID = filtered[i]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if username == "" && !strings.HasPrefix(filtered[i], "--") {
|
||||||
|
username = filtered[i]
|
||||||
|
} else {
|
||||||
|
output.Errorf("unknown flag: %s", filtered[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
output.Error("usage: hf user bind-agent <username> [--agent-id <id>] [--claw-identifier <id>]")
|
||||||
|
}
|
||||||
|
commands.RunUserBindAgent(username, agentID, clawID, accMgrTokenFlag)
|
||||||
case "update":
|
case "update":
|
||||||
if len(filtered) < 1 {
|
if len(filtered) < 1 {
|
||||||
output.Error("usage: hf user update <username> [--email ...] [--full-name ...] [--pass ...] [--active ...]")
|
output.Error("usage: hf user update <username> [--email ...] [--full-name ...] [--pass ...] [--active ...]")
|
||||||
@@ -289,6 +407,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)
|
||||||
}
|
}
|
||||||
@@ -1081,3 +1204,47 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
neturl "net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -14,14 +16,46 @@ import (
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Token string
|
Token string
|
||||||
|
APIKey string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Client with the given base URL and optional auth token.
|
// New creates a Client with the given base URL and optional auth token.
|
||||||
|
//
|
||||||
|
// The token is sent as Authorization: Bearer when it looks like a JWT
|
||||||
|
// (eyJ-prefixed, three dot-separated segments). Anything else is treated as
|
||||||
|
// an API key and sent via X-API-Key. This lets call sites that historically
|
||||||
|
// passed an api-key as a "token" (e.g. the value returned by passmgr.GetToken
|
||||||
|
// in padded-cell mode) authenticate correctly without per-callsite churn.
|
||||||
func New(baseURL, token string) *Client {
|
func New(baseURL, token string) *Client {
|
||||||
|
c := &Client{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
HTTPClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if isLikelyJWT(token) {
|
||||||
|
c.Token = token
|
||||||
|
} else if token != "" {
|
||||||
|
c.APIKey = token
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLikelyJWT returns true for tokens that look like a JSON Web Token:
|
||||||
|
// "eyJ"-prefixed (base64-encoded JSON header opening with `{"`) and exactly
|
||||||
|
// two dots separating header.payload.signature. API keys minted by the HF
|
||||||
|
// backend are hex (`/users/{id}/apikey` returns a 64-hex-char `key`); fabric
|
||||||
|
// keys use a `fak_` prefix. None of those match this shape.
|
||||||
|
func isLikelyJWT(token string) bool {
|
||||||
|
return strings.HasPrefix(token, "eyJ") && strings.Count(token, ".") == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithAPIKey creates a Client that authenticates using X-API-Key.
|
||||||
|
func NewWithAPIKey(baseURL, apiKey string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
Token: token,
|
APIKey: apiKey,
|
||||||
HTTPClient: &http.Client{
|
HTTPClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -38,14 +72,47 @@ func (e *RequestError) Error() string {
|
|||||||
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
|
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// guardPlaintextCreds refuses to transmit a token / API key over a plaintext
|
||||||
|
// http:// connection to a non-loopback host (prevents credential interception).
|
||||||
|
func (c *Client) guardPlaintextCreds() error {
|
||||||
|
if c.Token == "" && c.APIKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := neturl.Parse(c.BaseURL)
|
||||||
|
if err != nil || u.Scheme != "http" {
|
||||||
|
return nil // parse errors and https:// are fine
|
||||||
|
}
|
||||||
|
if isLoopbackHost(u.Hostname()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("refusing to send credentials over plaintext http:// to non-loopback host %q — use an https:// base URL (hf config set-url ...)", u.Hostname())
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLoopbackHost reports whether h is a loopback address or localhost name.
|
||||||
|
func isLoopbackHost(h string) bool {
|
||||||
|
h = strings.ToLower(h)
|
||||||
|
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(h); ip != nil {
|
||||||
|
return ip.IsLoopback()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Do executes an HTTP request and returns the response body bytes.
|
// Do executes an HTTP request and returns the response body bytes.
|
||||||
func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) {
|
func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) {
|
||||||
|
if err := c.guardPlaintextCreds(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
url := c.BaseURL + path
|
url := c.BaseURL + path
|
||||||
req, err := http.NewRequest(method, url, body)
|
req, err := http.NewRequest(method, url, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
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)
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
}
|
}
|
||||||
if body != nil {
|
if body != nil {
|
||||||
@@ -93,7 +160,7 @@ func (c *Client) Delete(path string) ([]byte, error) {
|
|||||||
|
|
||||||
// Health checks the API health endpoint and returns the response.
|
// Health checks the API health endpoint and returns the response.
|
||||||
func (c *Client) Health() (map[string]interface{}, error) {
|
func (c *Client) Health() (map[string]interface{}, error) {
|
||||||
data, err := c.Get("/api/health/")
|
data, err := c.Get("/health")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
104
internal/client/client_test.go
Normal file
104
internal/client/client_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsLikelyJWT(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
// Real JWT minted by an HS256 signer (header `{"alg":"HS256","typ":"JWT"}`).
|
||||||
|
{"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature", true, "valid JWT shape"},
|
||||||
|
// HF backend api-keys are 64-char hex from /users/{id}/apikey.
|
||||||
|
{"f654c3ff0bbc09e6a22294dfbbbff371a4550366849f59de68ddf064742831a0", false, "hex api-key"},
|
||||||
|
// Fabric api-keys carry a fak_ prefix.
|
||||||
|
{"fak_30791357ca11ac2ff963999bf265f6a5f240593eb01c06fc", false, "fabric api-key"},
|
||||||
|
// eyJ prefix without three segments isn't a JWT.
|
||||||
|
{"eyJabc", false, "prefix only"},
|
||||||
|
{"eyJabc.def", false, "two segments"},
|
||||||
|
// Empty / nonsense.
|
||||||
|
{"", false, "empty"},
|
||||||
|
{"....", false, "dots only"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := isLikelyJWT(c.in); got != c.want {
|
||||||
|
t.Errorf("isLikelyJWT(%q) = %v, want %v (%s)", c.in, got, c.want, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAutoSelectsAuthHeader(t *testing.T) {
|
||||||
|
// Capture which auth header reaches the server for each token shape.
|
||||||
|
var lastReq *http.Request
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastReq = r
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.WriteString(w, "{}")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// api-key path: should go via X-API-Key, NOT Authorization.
|
||||||
|
apiKey := "f654c3ff0bbc09e6a22294dfbbbff371a4550366849f59de68ddf064742831a0"
|
||||||
|
if _, err := New(srv.URL, apiKey).Get("/anything"); err != nil {
|
||||||
|
t.Fatalf("api-key call failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("X-API-Key"); got != apiKey {
|
||||||
|
t.Errorf("api-key not sent as X-API-Key (got %q)", got)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Errorf("api-key leaked into Authorization header (got %q)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT path: should go via Authorization: Bearer, NOT X-API-Key.
|
||||||
|
jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature"
|
||||||
|
if _, err := New(srv.URL, jwt).Get("/anything"); err != nil {
|
||||||
|
t.Fatalf("jwt call failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") || !strings.HasSuffix(got, jwt) {
|
||||||
|
t.Errorf("jwt not sent as Bearer (got %q)", got)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("X-API-Key"); got != "" {
|
||||||
|
t.Errorf("jwt leaked into X-API-Key header (got %q)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty token: neither header set.
|
||||||
|
if _, err := New(srv.URL, "").Get("/anything"); err != nil {
|
||||||
|
t.Fatalf("empty-token call failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Errorf("empty token set Authorization (got %q)", got)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("X-API-Key"); got != "" {
|
||||||
|
t.Errorf("empty token set X-API-Key (got %q)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWithAPIKeyAlwaysUsesAPIKeyHeader(t *testing.T) {
|
||||||
|
// Even if someone passes a JWT-shaped string via NewWithAPIKey, it must
|
||||||
|
// still go via X-API-Key — the explicit constructor wins.
|
||||||
|
var lastReq *http.Request
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastReq = r
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.WriteString(w, "{}")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
jwtShape := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature"
|
||||||
|
if _, err := NewWithAPIKey(srv.URL, jwtShape).Get("/anything"); err != nil {
|
||||||
|
t.Fatalf("call failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("X-API-Key"); got != jwtShape {
|
||||||
|
t.Errorf("NewWithAPIKey didn't use X-API-Key (got %q)", got)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Errorf("NewWithAPIKey set Authorization (got %q)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/commands/agent.go
Normal file
140
internal/commands/agent.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// Package commands — agent runtime-status command (`hf agent status`).
|
||||||
|
//
|
||||||
|
// Wraps the plugin-facing `POST /calendar/agent/status` endpoint so agents
|
||||||
|
// driven from `pcexec` (which sets AGENT_ID/CLAW_IDENTIFIER env) can report
|
||||||
|
// their status from a workflow without writing curl in the middle of a
|
||||||
|
// `flow.md` procedure.
|
||||||
|
//
|
||||||
|
// The endpoint itself is unauthenticated at the HTTP layer — it identifies
|
||||||
|
// the agent purely from X-Agent-ID + X-Claw-Identifier headers — so this
|
||||||
|
// command does NOT call `ResolveToken`. Calling it from outside a pcexec
|
||||||
|
// session will fail because AGENT_ID/CLAW_IDENTIFIER won't be set.
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
|
||||||
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunAgentStatus implements `hf agent status --set <status>`.
|
||||||
|
//
|
||||||
|
// Supported statuses (mirrors backend `AgentStatus` enum):
|
||||||
|
// idle | busy | on_call | exhausted | offline
|
||||||
|
//
|
||||||
|
// For `exhausted`, an optional `--reason <rate_limit|billing>` and
|
||||||
|
// `--recovery-at <ISO-8601>` can be provided.
|
||||||
|
func RunAgentStatus(args []string) {
|
||||||
|
target := ""
|
||||||
|
reason := ""
|
||||||
|
recoveryAt := ""
|
||||||
|
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--set":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("usage: hf agent status --set <idle|busy|on_call|exhausted|offline> [--reason <rate_limit|billing>] [--recovery-at <iso>]")
|
||||||
|
}
|
||||||
|
target = args[i+1]
|
||||||
|
i++
|
||||||
|
case "--reason":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--reason requires a value")
|
||||||
|
}
|
||||||
|
reason = args[i+1]
|
||||||
|
i++
|
||||||
|
case "--recovery-at":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--recovery-at requires an ISO-8601 timestamp")
|
||||||
|
}
|
||||||
|
recoveryAt = args[i+1]
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if target == "" {
|
||||||
|
output.Error("--set <status> is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
agentID := strings.TrimSpace(os.Getenv("AGENT_ID"))
|
||||||
|
clawID := strings.TrimSpace(os.Getenv("CLAW_IDENTIFIER"))
|
||||||
|
if clawID == "" {
|
||||||
|
// Match the plugin convention: hostname fallback when CLAW_IDENTIFIER
|
||||||
|
// is unset. Most pcexec callers won't have it set in env.
|
||||||
|
if h, err := os.Hostname(); err == nil {
|
||||||
|
clawID = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if agentID == "" {
|
||||||
|
output.Error("AGENT_ID env is not set — run via pcexec or export AGENT_ID first")
|
||||||
|
}
|
||||||
|
if clawID == "" {
|
||||||
|
output.Error("CLAW_IDENTIFIER env not set and hostname() failed — set CLAW_IDENTIFIER explicitly")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("config error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"agent_id": agentID,
|
||||||
|
"claw_identifier": clawID,
|
||||||
|
"status": target,
|
||||||
|
}
|
||||||
|
if reason != "" {
|
||||||
|
body["exhaust_reason"] = reason
|
||||||
|
}
|
||||||
|
if recoveryAt != "" {
|
||||||
|
body["recovery_at"] = recoveryAt
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("cannot marshal payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(cfg.BaseURL, "/") + "/calendar/agent/status"
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("cannot build request: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Agent-ID", agentID)
|
||||||
|
req.Header.Set("X-Claw-Identifier", clawID)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
output.Errorf("backend returned %d: %s", resp.StatusCode, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.JSONMode {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
fmt.Println(buf.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output.PrintKeyValue(
|
||||||
|
"agent_id", agentID,
|
||||||
|
"claw_identifier", clawID,
|
||||||
|
"status", target,
|
||||||
|
"ok", "true",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode"
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode"
|
||||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
||||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr"
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr"
|
||||||
@@ -20,11 +23,16 @@ func ResolveToken(tokenFlag string) string {
|
|||||||
}
|
}
|
||||||
return tok
|
return tok
|
||||||
}
|
}
|
||||||
// manual mode
|
// manual mode — prefer the explicit flag, else fall back to the HF_TOKEN
|
||||||
if tokenFlag == "" {
|
// env var so the token need not appear in argv (visible via `ps`/history).
|
||||||
output.Error("--token <token> required or execute this with pcexec")
|
if tokenFlag != "" {
|
||||||
|
return tokenFlag
|
||||||
}
|
}
|
||||||
return tokenFlag
|
if env := strings.TrimSpace(os.Getenv("HF_TOKEN")); env != "" {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
output.Error("--token <token> or HF_TOKEN env required, or execute this with pcexec")
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// RejectTokenInPaddedCell checks if --token was passed in padded-cell mode
|
// RejectTokenInPaddedCell checks if --token was passed in padded-cell mode
|
||||||
|
|||||||
@@ -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>")
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
type essentialResponse struct {
|
type essentialResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
EssentialCode string `json:"essential_code"`
|
EssentialCode string `json:"essential_code"`
|
||||||
ProposalID int `json:"proposal_id"`
|
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
@@ -49,7 +48,7 @@ func RunEssentialList(args []string, tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
c := client.New(cfg.BaseURL, token)
|
||||||
data, err := c.Get("/proposes/" + proposalCode + "/essentials")
|
data, err := c.Get(proposalPath(c, proposalCode) + "/essentials")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("failed to list essentials: %v", err)
|
output.Errorf("failed to list essentials: %v", err)
|
||||||
}
|
}
|
||||||
@@ -146,7 +145,7 @@ func RunEssentialCreate(args []string, tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
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 {
|
if err != nil {
|
||||||
output.Errorf("failed to create essential: %v", err)
|
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)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
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 {
|
if err != nil {
|
||||||
output.Errorf("failed to update essential: %v", err)
|
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)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
c := client.New(cfg.BaseURL, token)
|
||||||
_, err = c.Delete("/proposes/" + proposalCode + "/essentials/" + essentialCode)
|
_, err = c.Delete(proposalPath(c, proposalCode) + "/essentials/" + essentialCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("failed to delete essential: %v", err)
|
output.Errorf("failed to delete essential: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func RunMeetingList(args []string, tokenFlag string) {
|
|||||||
output.Error("--project requires a value")
|
output.Error("--project requires a value")
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
query = appendQuery(query, "project", args[i])
|
query = appendQuery(query, "project_code", args[i])
|
||||||
case "--status":
|
case "--status":
|
||||||
if i+1 >= len(args) {
|
if i+1 >= len(args) {
|
||||||
output.Error("--status requires a value")
|
output.Error("--status requires a value")
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func RunMilestoneList(args []string, tokenFlag string) {
|
|||||||
output.Error("--project requires a value")
|
output.Error("--project requires a value")
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
query = appendQuery(query, "project", args[i])
|
query = appendQuery(query, "project_code", args[i])
|
||||||
case "--status":
|
case "--status":
|
||||||
if i+1 >= len(args) {
|
if i+1 >= len(args) {
|
||||||
output.Error("--status requires a value")
|
output.Error("--status requires a value")
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import (
|
|||||||
|
|
||||||
// monitorOverviewResponse matches the backend monitor overview schema.
|
// monitorOverviewResponse matches the backend monitor overview schema.
|
||||||
type monitorOverviewResponse struct {
|
type monitorOverviewResponse struct {
|
||||||
TotalServers int `json:"total_servers"`
|
Tasks interface{} `json:"tasks"`
|
||||||
OnlineServers int `json:"online_servers"`
|
Providers interface{} `json:"providers"`
|
||||||
|
Servers []monitorServerResponse `json:"servers"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// monitorServerResponse matches the backend monitor server schema.
|
// monitorServerResponse matches the backend monitor server schema.
|
||||||
@@ -28,8 +30,32 @@ type monitorServerResponse struct {
|
|||||||
|
|
||||||
// monitorAPIKeyResponse matches the backend monitor API key schema.
|
// monitorAPIKeyResponse matches the backend monitor API key schema.
|
||||||
type monitorAPIKeyResponse struct {
|
type monitorAPIKeyResponse struct {
|
||||||
Identifier string `json:"identifier"`
|
ServerID int `json:"server_id"`
|
||||||
APIKey string `json:"api_key"`
|
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`.
|
// RunMonitorOverview implements `hf monitor overview`.
|
||||||
@@ -40,7 +66,7 @@ func RunMonitorOverview(tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
c := client.New(cfg.BaseURL, token)
|
||||||
data, err := c.Get("/monitor/overview")
|
data, err := c.Get("/monitor/public/overview")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("failed to get monitor overview: %v", err)
|
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)
|
output.Errorf("cannot parse monitor overview: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
online := 0
|
||||||
|
for _, s := range o.Servers {
|
||||||
|
if s.Status == "online" {
|
||||||
|
online++
|
||||||
|
}
|
||||||
|
}
|
||||||
output.PrintKeyValue(
|
output.PrintKeyValue(
|
||||||
"total-servers", fmt.Sprintf("%d", o.TotalServers),
|
"total-servers", fmt.Sprintf("%d", len(o.Servers)),
|
||||||
"online-servers", fmt.Sprintf("%d", o.OnlineServers),
|
"online-servers", fmt.Sprintf("%d", online),
|
||||||
|
"generated-at", o.GeneratedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +106,7 @@ func RunMonitorServerList(tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
c := client.New(cfg.BaseURL, token)
|
||||||
data, err := c.Get("/monitor/servers")
|
data, err := c.Get("/monitor/admin/servers")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("failed to list monitor servers: %v", err)
|
output.Errorf("failed to list monitor servers: %v", err)
|
||||||
}
|
}
|
||||||
@@ -116,39 +149,37 @@ func RunMonitorServerGet(identifier, tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
c := client.New(cfg.BaseURL, token)
|
||||||
data, err := c.Get("/monitor/servers/" + identifier)
|
servers := monitorServerList(c)
|
||||||
if err != nil {
|
var found *monitorServerResponse
|
||||||
output.Errorf("failed to get server: %v", err)
|
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 {
|
if output.JSONMode {
|
||||||
var raw json.RawMessage
|
output.PrintJSON(found)
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
|
||||||
output.Errorf("invalid JSON response: %v", err)
|
|
||||||
}
|
|
||||||
output.PrintJSON(raw)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var s monitorServerResponse
|
|
||||||
if err := json.Unmarshal(data, &s); err != nil {
|
|
||||||
output.Errorf("cannot parse server: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
name := ""
|
name := ""
|
||||||
if s.DisplayName != nil {
|
if found.DisplayName != nil {
|
||||||
name = *s.DisplayName
|
name = *found.DisplayName
|
||||||
}
|
}
|
||||||
lastSeen := ""
|
lastSeen := ""
|
||||||
if s.LastSeen != nil {
|
if found.LastSeen != nil {
|
||||||
lastSeen = *s.LastSeen
|
lastSeen = *found.LastSeen
|
||||||
}
|
}
|
||||||
output.PrintKeyValue(
|
output.PrintKeyValue(
|
||||||
"identifier", s.Identifier,
|
"identifier", found.Identifier,
|
||||||
"name", name,
|
"name", name,
|
||||||
"status", s.Status,
|
"status", found.Status,
|
||||||
"last-seen", lastSeen,
|
"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)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
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 {
|
if err != nil {
|
||||||
output.Errorf("failed to create server: %v", err)
|
output.Errorf("failed to create server: %v", err)
|
||||||
}
|
}
|
||||||
@@ -223,7 +254,8 @@ func RunMonitorServerDelete(identifier, tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
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 {
|
if err != nil {
|
||||||
output.Errorf("failed to delete server: %v", err)
|
output.Errorf("failed to delete server: %v", err)
|
||||||
}
|
}
|
||||||
@@ -238,7 +270,8 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
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 {
|
if err != nil {
|
||||||
output.Errorf("failed to generate API key: %v", err)
|
output.Errorf("failed to generate API key: %v", err)
|
||||||
}
|
}
|
||||||
@@ -258,8 +291,9 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
output.PrintKeyValue(
|
output.PrintKeyValue(
|
||||||
"identifier", k.Identifier,
|
"server-id", fmt.Sprintf("%d", k.ServerID),
|
||||||
"api-key", k.APIKey,
|
"api-key", k.APIKey,
|
||||||
|
"message", k.Message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +305,8 @@ func RunMonitorAPIKeyRevoke(identifier, tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
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 {
|
if err != nil {
|
||||||
output.Errorf("failed to revoke API key: %v", err)
|
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) {
|
func TestEssentialList_JSONOutput(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
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()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -204,16 +213,25 @@ func TestEssentialList_JSONOutput(t *testing.T) {
|
|||||||
|
|
||||||
func TestEssentialCreate_Success(t *testing.T) {
|
func TestEssentialCreate_Success(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
switch {
|
||||||
t.Errorf("expected POST; got: %s", r.Method)
|
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()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -234,11 +252,20 @@ func TestEssentialCreate_Success(t *testing.T) {
|
|||||||
|
|
||||||
func TestEssentialUpdate_Success(t *testing.T) {
|
func TestEssentialUpdate_Success(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "PATCH" {
|
switch {
|
||||||
t.Errorf("expected PATCH; got: %s", r.Method)
|
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()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -259,11 +286,20 @@ func TestEssentialUpdate_Success(t *testing.T) {
|
|||||||
|
|
||||||
func TestEssentialDelete_Success(t *testing.T) {
|
func TestEssentialDelete_Success(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "DELETE" {
|
switch {
|
||||||
t.Errorf("expected DELETE; got: %s", r.Method)
|
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()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -306,20 +342,29 @@ func TestProposalAccept_MissingMilestone(t *testing.T) {
|
|||||||
|
|
||||||
func TestProposalAccept_Success(t *testing.T) {
|
func TestProposalAccept_Success(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
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()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -421,7 +466,7 @@ func TestTaskCreate_StoryTypeOnlyRestricted(t *testing.T) {
|
|||||||
|
|
||||||
func TestProposalList_Success(t *testing.T) {
|
func TestProposalList_Success(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
}
|
}
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
@@ -444,7 +489,7 @@ func TestProposalList_Success(t *testing.T) {
|
|||||||
cliPath := filepath.Join(tmpDir, "hf")
|
cliPath := filepath.Join(tmpDir, "hf")
|
||||||
buildCLI(t, cliPath)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
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) {
|
func TestProposalList_JSONOutput(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
w.WriteHeader(200)
|
||||||
json.NewEncoder(w).Encode([]interface{}{
|
json.NewEncoder(w).Encode([]interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
@@ -472,7 +520,7 @@ func TestProposalList_JSONOutput(t *testing.T) {
|
|||||||
cliPath := filepath.Join(tmpDir, "hf")
|
cliPath := filepath.Join(tmpDir, "hf")
|
||||||
buildCLI(t, cliPath)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
t.Fatalf("unexpected error: %v; out=%s", err, out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
"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/config"
|
||||||
@@ -23,11 +24,44 @@ type proposeResponse struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
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>`.
|
// RunProposeList implements `hf propose list --project <project-code>`.
|
||||||
func RunProposeList(args []string, tokenFlag string) {
|
func RunProposeList(args []string, tokenFlag string) {
|
||||||
token := ResolveToken(tokenFlag)
|
token := ResolveToken(tokenFlag)
|
||||||
|
|
||||||
query := ""
|
project := ""
|
||||||
|
query := url.Values{}
|
||||||
for i := 0; i < len(args); i++ {
|
for i := 0; i < len(args); i++ {
|
||||||
switch args[i] {
|
switch args[i] {
|
||||||
case "--project":
|
case "--project":
|
||||||
@@ -35,32 +69,39 @@ func RunProposeList(args []string, tokenFlag string) {
|
|||||||
output.Error("--project requires a value")
|
output.Error("--project requires a value")
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
query = appendQuery(query, "project", args[i])
|
project = args[i]
|
||||||
case "--status":
|
case "--status":
|
||||||
if i+1 >= len(args) {
|
if i+1 >= len(args) {
|
||||||
output.Error("--status requires a value")
|
output.Error("--status requires a value")
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
query = appendQuery(query, "status", args[i])
|
query.Set("status", args[i])
|
||||||
case "--order-by":
|
case "--order-by":
|
||||||
if i+1 >= len(args) {
|
if i+1 >= len(args) {
|
||||||
output.Error("--order-by requires a value")
|
output.Error("--order-by requires a value")
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
query = appendQuery(query, "order_by", args[i])
|
query.Set("order_by", args[i])
|
||||||
default:
|
default:
|
||||||
output.Errorf("unknown flag: %s", args[i])
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
legacyPath := false
|
||||||
|
if project == "" {
|
||||||
|
legacyPath = true
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
c := client.New(cfg.BaseURL, token)
|
||||||
path := "/proposes"
|
path := "/projects/" + project + "/proposals"
|
||||||
if query != "" {
|
if legacyPath {
|
||||||
path += "?" + query
|
path = "/proposes"
|
||||||
|
}
|
||||||
|
if encoded := query.Encode(); encoded != "" {
|
||||||
|
path += "?" + encoded
|
||||||
}
|
}
|
||||||
data, err := c.Get(path)
|
data, err := c.Get(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,7 +146,7 @@ func RunProposeGet(proposeCode, tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
c := client.New(cfg.BaseURL, token)
|
||||||
data, err := c.Get("/proposes/" + proposeCode)
|
data, err := c.Get(proposalPath(c, proposeCode))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("failed to get proposal: %v", err)
|
output.Errorf("failed to get proposal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -178,9 +219,8 @@ func RunProposeCreate(args []string, tokenFlag string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"project_code": project,
|
"title": title,
|
||||||
"title": title,
|
"description": desc,
|
||||||
"description": desc,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
@@ -193,7 +233,7 @@ func RunProposeCreate(args []string, tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
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 {
|
if err != nil {
|
||||||
output.Errorf("failed to create proposal: %v", err)
|
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)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
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 {
|
if err != nil {
|
||||||
output.Errorf("failed to update proposal: %v", err)
|
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)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
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 {
|
if err != nil {
|
||||||
output.Errorf("failed to accept proposal: %v", err)
|
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 {
|
if err := json.Unmarshal(data, &resp); err == nil && len(resp.GeneratedTasks) > 0 {
|
||||||
fmt.Printf("\nGenerated %d story task(s):\n", len(resp.GeneratedTasks))
|
fmt.Printf("\nGenerated %d story task(s):\n", len(resp.GeneratedTasks))
|
||||||
for _, gt := range resp.GeneratedTasks {
|
for _, gt := range resp.GeneratedTasks {
|
||||||
code := ""
|
code := "(no task_code)"
|
||||||
if gt.TaskCode != nil {
|
if gt.TaskCode != nil {
|
||||||
code = *gt.TaskCode
|
code = *gt.TaskCode
|
||||||
}
|
}
|
||||||
@@ -380,7 +420,7 @@ func RunProposeReject(proposeCode string, args []string, tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
c := client.New(cfg.BaseURL, token)
|
||||||
_, err = c.Post("/proposes/"+proposeCode+"/reject", body)
|
_, err = c.Post(proposalPath(c, proposeCode)+"/reject", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("failed to reject proposal: %v", err)
|
output.Errorf("failed to reject proposal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -397,7 +437,7 @@ func RunProposeReopen(proposeCode, tokenFlag string) {
|
|||||||
output.Errorf("config error: %v", err)
|
output.Errorf("config error: %v", err)
|
||||||
}
|
}
|
||||||
c := client.New(cfg.BaseURL, token)
|
c := client.New(cfg.BaseURL, token)
|
||||||
_, err = c.Post("/proposes/"+proposeCode+"/reopen", nil)
|
_, err = c.Post(proposalPath(c, proposeCode)+"/reopen", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("failed to reopen proposal: %v", err)
|
output.Errorf("failed to reopen proposal: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
165
internal/commands/schedule_type.go
Normal file
165
internal/commands/schedule_type.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -38,13 +38,13 @@ func RunTaskList(args []string, tokenFlag string) {
|
|||||||
output.Error("--project requires a value")
|
output.Error("--project requires a value")
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
query = appendQuery(query, "project", args[i])
|
query = appendQuery(query, "project_code", args[i])
|
||||||
case "--milestone":
|
case "--milestone":
|
||||||
if i+1 >= len(args) {
|
if i+1 >= len(args) {
|
||||||
output.Error("--milestone requires a value")
|
output.Error("--milestone requires a value")
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
query = appendQuery(query, "milestone", args[i])
|
query = appendQuery(query, "milestone_code", args[i])
|
||||||
case "--status":
|
case "--status":
|
||||||
if i+1 >= len(args) {
|
if i+1 >= len(args) {
|
||||||
output.Error("--status requires a value")
|
output.Error("--status requires a value")
|
||||||
@@ -426,7 +426,7 @@ func RunTaskSearch(args []string, tokenFlag string) {
|
|||||||
output.Error("--project requires a value")
|
output.Error("--project requires a value")
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
query = appendQuery(query, "project", args[i])
|
query = appendQuery(query, "project_code", args[i])
|
||||||
case "--status":
|
case "--status":
|
||||||
if i+1 >= len(args) {
|
if i+1 >= len(args) {
|
||||||
output.Error("--status requires a value")
|
output.Error("--status requires a value")
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
||||||
@@ -23,6 +25,7 @@ type userResponse struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
RoleID *int `json:"role_id"`
|
RoleID *int `json:"role_id"`
|
||||||
RoleName *string `json:"role_name"`
|
RoleName *string `json:"role_name"`
|
||||||
|
DiscordUserID *string `json:"discord_user_id"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +140,100 @@ type userCreatePayload struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
FullName *string `json:"full_name,omitempty"`
|
FullName *string `json:"full_name,omitempty"`
|
||||||
Password *string `json:"password,omitempty"`
|
Password *string `json:"password,omitempty"`
|
||||||
|
DiscordUserID *string `json:"discord_user_id,omitempty"`
|
||||||
|
// Agent binding — both fields go together or both stay nil.
|
||||||
|
// Backend rejects (400) if only one is set.
|
||||||
|
AgentID *string `json:"agent_id,omitempty"`
|
||||||
|
ClawIdentifier *string `json:"claw_identifier,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userBindAgentPayload is the JSON body for PATCH /users/{id}/bind-agent.
|
||||||
|
type userBindAgentPayload struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
ClawIdentifier string `json:"claw_identifier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAgentBinding picks the final (agent_id, claw_identifier) pair
|
||||||
|
// to send to the backend.
|
||||||
|
//
|
||||||
|
// Explicit flags win. If either is empty, fall back to the pcexec env
|
||||||
|
// (AGENT_ID) and to `openclaw config get plugins.harbor-forge.identifier`
|
||||||
|
// for the claw — same convention as the openclaw plugin's heartbeat.
|
||||||
|
//
|
||||||
|
// Honours the backend's "both or neither" invariant: if only one side
|
||||||
|
// can be resolved, returns (nil, nil) — caller's intent of creating a
|
||||||
|
// non-agent user is preserved instead of producing a 400. When the
|
||||||
|
// caller actually NEEDS the binding (e.g. `hf user bind-agent` is the
|
||||||
|
// whole point of the command), set `requireBoth=true` to fail loudly
|
||||||
|
// instead.
|
||||||
|
func resolveAgentBinding(explicitAgentID, explicitClawID string, requireBoth bool) (*string, *string) {
|
||||||
|
agentID := strings.TrimSpace(explicitAgentID)
|
||||||
|
clawID := strings.TrimSpace(explicitClawID)
|
||||||
|
|
||||||
|
if agentID == "" {
|
||||||
|
agentID = strings.TrimSpace(os.Getenv("AGENT_ID"))
|
||||||
|
}
|
||||||
|
if clawID == "" {
|
||||||
|
if v, err := exec.Command("openclaw", "config", "get", "plugins.harbor-forge.identifier").Output(); err == nil {
|
||||||
|
clawID = strings.TrimSpace(string(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if agentID == "" && clawID == "" {
|
||||||
|
if requireBoth {
|
||||||
|
output.Error("--agent-id and --claw-identifier required (AGENT_ID env and `openclaw config get plugins.harbor-forge.identifier` both empty)")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if agentID == "" || clawID == "" {
|
||||||
|
if requireBoth {
|
||||||
|
output.Errorf(
|
||||||
|
"could not resolve agent binding pair: agent_id=%q claw_identifier=%q (need both)",
|
||||||
|
agentID, clawID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &agentID, &clawID
|
||||||
|
}
|
||||||
|
|
||||||
|
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`.
|
// RunUserCreate implements `hf user create`.
|
||||||
func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string) {
|
//
|
||||||
|
// Agent binding: if `agentIDFlag` + `clawIDFlag` are both set (or both
|
||||||
|
// resolvable from env), the backend creates the matching agents row in
|
||||||
|
// the same transaction. Partial pair → treated as "neither" so callers
|
||||||
|
// who didn't want a binding still get a normal user.
|
||||||
|
func RunUserCreate(username, password, email, fullName, discordUserID, agentIDFlag, clawIDFlag, accMgrTokenFlag string) {
|
||||||
// Resolve account-manager token
|
// Resolve account-manager token
|
||||||
var accMgrToken string
|
var accMgrToken string
|
||||||
if mode.IsPaddedCell() {
|
if mode.IsPaddedCell() {
|
||||||
@@ -181,10 +274,21 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
|
|||||||
Email: email,
|
Email: email,
|
||||||
Password: &password,
|
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 != "" {
|
if fullName != "" {
|
||||||
payload.FullName = &fullName
|
payload.FullName = &fullName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent binding — resolve from flags + pcexec env fallback.
|
||||||
|
if agentID, clawID := resolveAgentBinding(agentIDFlag, clawIDFlag, false); agentID != nil && clawID != nil {
|
||||||
|
payload.AgentID = agentID
|
||||||
|
payload.ClawIdentifier = clawID
|
||||||
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("cannot marshal payload: %v", err)
|
output.Errorf("cannot marshal payload: %v", err)
|
||||||
@@ -194,7 +298,7 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("config error: %v", err)
|
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))
|
data, err := c.Post("/users", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.Errorf("failed to create user: %v", err)
|
output.Errorf("failed to create user: %v", err)
|
||||||
@@ -216,6 +320,28 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string)
|
|||||||
fmt.Printf("user created: %s\n", u.Username)
|
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>`.
|
// RunUserUpdate implements `hf user update <username>`.
|
||||||
func RunUserUpdate(username string, args []string, tokenFlag string) {
|
func RunUserUpdate(username string, args []string, tokenFlag string) {
|
||||||
token := ResolveToken(tokenFlag)
|
token := ResolveToken(tokenFlag)
|
||||||
@@ -329,3 +455,139 @@ 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunUserBindAgent implements `hf user bind-agent <username>`.
|
||||||
|
//
|
||||||
|
// Backfills the agents row for an existing user that was created via
|
||||||
|
// the old `hf user create` (which did not accept --agent-id /
|
||||||
|
// --claw-identifier). Hits PATCH /users/{username}/bind-agent.
|
||||||
|
//
|
||||||
|
// Uses an account-manager token like `hf user create` does — same
|
||||||
|
// permission surface (`account.create`).
|
||||||
|
func RunUserBindAgent(username, agentIDFlag, clawIDFlag, accMgrTokenFlag string) {
|
||||||
|
if username == "" {
|
||||||
|
output.Error("usage: hf user bind-agent <username> [--agent-id <id>] [--claw-identifier <id>]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve account-manager token (same as RunUserCreate).
|
||||||
|
var accMgrToken string
|
||||||
|
if mode.IsPaddedCell() {
|
||||||
|
if accMgrTokenFlag != "" {
|
||||||
|
output.Error("padded-cell installed, --acc-mgr-token flag disabled, use command directly")
|
||||||
|
}
|
||||||
|
tok, err := passmgr.GetAccountManagerToken()
|
||||||
|
if err != nil {
|
||||||
|
output.Error("--acc-mgr-token <token> required or execute with pcexec")
|
||||||
|
}
|
||||||
|
accMgrToken = tok
|
||||||
|
} else {
|
||||||
|
if accMgrTokenFlag == "" {
|
||||||
|
output.Error("--acc-mgr-token <token> required or execute with pcexec")
|
||||||
|
}
|
||||||
|
accMgrToken = accMgrTokenFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve agent binding — REQUIRED for this command (errors out on partial).
|
||||||
|
agentID, clawID := resolveAgentBinding(agentIDFlag, clawIDFlag, true)
|
||||||
|
if agentID == nil || clawID == nil {
|
||||||
|
output.Error("--agent-id and --claw-identifier could not be resolved (bug — requireBoth should have errored already)")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := userBindAgentPayload{
|
||||||
|
AgentID: *agentID,
|
||||||
|
ClawIdentifier: *clawID,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
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.NewWithAPIKey(cfg.BaseURL, accMgrToken)
|
||||||
|
data, err := c.Patch("/users/"+username+"/bind-agent", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to bind agent: %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 u userResponse
|
||||||
|
if err := json.Unmarshal(data, &u); err != nil {
|
||||||
|
fmt.Printf("agent bound to %s (agent_id=%s, claw=%s)\n", username, *agentID, *clawID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output.PrintKeyValue(
|
||||||
|
"username", u.Username,
|
||||||
|
"user_id", fmt.Sprint(u.ID),
|
||||||
|
"agent_id", *agentID,
|
||||||
|
"claw_identifier", *clawID,
|
||||||
|
"message", "agent binding written",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()},
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ func CommandSurface() []Group {
|
|||||||
{Name: "version", Description: "Show CLI version", Permitted: true},
|
{Name: "version", Description: "Show CLI version", Permitted: true},
|
||||||
{Name: "health", Description: "Check API health", Permitted: true},
|
{Name: "health", Description: "Check API health", Permitted: true},
|
||||||
{Name: "config", Description: "View and manage CLI configuration", Permitted: true},
|
{Name: "config", Description: "View and manage CLI configuration", Permitted: true},
|
||||||
|
{
|
||||||
|
Name: "agent",
|
||||||
|
Description: "Runtime status reporting for the calling agent (uses AGENT_ID/CLAW_IDENTIFIER env)",
|
||||||
|
SubCommands: []Command{
|
||||||
|
{Name: "status", Description: "Report runtime status: hf agent status --set <idle|busy|on_call|exhausted|offline>", Permitted: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "user",
|
Name: "user",
|
||||||
Description: "Manage users",
|
Description: "Manage users",
|
||||||
@@ -40,6 +47,8 @@ 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},
|
||||||
|
{Name: "bind-agent", Description: "Backfill the agents row for an existing user (acc-mgr token; use --agent-id / --claw-identifier or AGENT_ID env)", Permitted: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,7 +76,7 @@ func CommandSurface() []Group {
|
|||||||
SubCommands: []Command{
|
SubCommands: []Command{
|
||||||
{Name: "list", Description: "List projects", Permitted: has(perms, "project.read")},
|
{Name: "list", Description: "List projects", Permitted: has(perms, "project.read")},
|
||||||
{Name: "get", Description: "Show a project by code", Permitted: has(perms, "project.read")},
|
{Name: "get", Description: "Show a project by code", Permitted: has(perms, "project.read")},
|
||||||
{Name: "create", Description: "Create a project", Permitted: has(perms, "project.write")},
|
{Name: "create", Description: "Create a project", Permitted: has(perms, "project.create")},
|
||||||
{Name: "update", Description: "Update a project", Permitted: has(perms, "project.write")},
|
{Name: "update", Description: "Update a project", Permitted: has(perms, "project.write")},
|
||||||
{Name: "delete", Description: "Delete a project", Permitted: has(perms, "project.delete")},
|
{Name: "delete", Description: "Delete a project", Permitted: has(perms, "project.delete")},
|
||||||
{Name: "members", Description: "List project members", Permitted: has(perms, "project.read")},
|
{Name: "members", Description: "List project members", Permitted: has(perms, "project.read")},
|
||||||
@@ -180,6 +189,16 @@ 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 {
|
||||||
@@ -205,7 +224,12 @@ func loadPermissionState(token string) permissionState {
|
|||||||
return permissionState{Known: false, Permissions: map[string]struct{}{}}
|
return permissionState{Known: false, Permissions: map[string]struct{}{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
c := client.New(cfg.BaseURL, token)
|
// passmgr.GetToken() returns an api-key in padded-cell mode (provisioned
|
||||||
|
// by scripts/provision-hf-accounts.sh via `hf user reset-apikey`), so go
|
||||||
|
// through the X-API-Key path explicitly. client.New also auto-detects this
|
||||||
|
// nowadays, but the explicit call keeps the introspection path independent
|
||||||
|
// of that heuristic.
|
||||||
|
c := client.NewWithAPIKey(cfg.BaseURL, token)
|
||||||
data, err := c.Get("/auth/me/permissions")
|
data, err := c.Get("/auth/me/permissions")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return permissionState{Known: false, Permissions: map[string]struct{}{}}
|
return permissionState{Known: false, Permissions: map[string]struct{}{}}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user